6 小时以前 47bae1f938f915206e3934ea960aff975e5738c9
feat(teachingDemo): 新增工艺路线与BOM教学演示模块

新增动态工厂演示功能,帮助用户理解BOM和工艺路线概念:
- BOM树形结构演示
- 工艺路线流程演示
- BOM与工艺路线联动演示
- 动态工厂生产线演示(履带、工序节点、投料动画)
- 更新.gitignore忽略.claude目录
已添加8个文件
已修改2个文件
3973 ■■■■■ 文件已修改
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.js 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/BomDemo.vue 430 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/CombinedDemo.vue 518 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/FactoryDemo.vue 1153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue 535 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/components/DemoControls.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/components/ProcessNode.vue 354 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/components/TreeNode.vue 423 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/index.vue 333 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -21,5 +21,8 @@
*.sln
*.local
# Claude Code
.claude/
package-lock.json
yarn.lock
src/router/index.js
@@ -97,6 +97,44 @@
      },
    ],
  },
  // æ•™å­¦æ¼”示模块
  {
    path: "/productionManagement/teachingDemo",
    component: Layout,
    hidden: true,
    children: [
      {
        path: "",
        component: () => import("@/views/productionManagement/teachingDemo/index.vue"),
        name: "TeachingDemo",
        meta: { title: "工艺路线与BOM教学演示", icon: "education" },
      },
      {
        path: "bom",
        component: () => import("@/views/productionManagement/teachingDemo/BomDemo.vue"),
        name: "BomDemo",
        meta: { title: "BOM结构演示", activeMenu: "/productionManagement/teachingDemo" },
      },
      {
        path: "processRoute",
        component: () => import("@/views/productionManagement/teachingDemo/ProcessRouteDemo.vue"),
        name: "ProcessRouteDemo",
        meta: { title: "工艺路线演示", activeMenu: "/productionManagement/teachingDemo" },
      },
      {
        path: "combined",
        component: () => import("@/views/productionManagement/teachingDemo/CombinedDemo.vue"),
        name: "CombinedDemo",
        meta: { title: "联动演示", activeMenu: "/productionManagement/teachingDemo" },
      },
      {
        path: "factory",
        component: () => import("@/views/productionManagement/teachingDemo/FactoryDemo.vue"),
        name: "FactoryDemo",
        meta: { title: "动态工厂演示", activeMenu: "/productionManagement/teachingDemo" },
      },
    ],
  },
  {
    path: "/user",
    component: Layout,
src/views/productionManagement/teachingDemo/BomDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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>
src/views/productionManagement/teachingDemo/CombinedDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,518 @@
<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>
src/views/productionManagement/teachingDemo/FactoryDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1153 @@
<template>
  <div class="app-container factory-demo">
    <PageHeader content="动态工厂生产线演示">
      <template #right-button>
        <el-button @click="goBack">返回</el-button>
      </template>
    </PageHeader>
    <div class="control-panel">
      <el-row :gutter="20" align="middle">
        <el-col :span="6">
          <el-select v-model="selectedRouteId" placeholder="选择工艺路线" filterable style="width: 100%" @change="handleRouteChange">
            <el-option v-for="item in routeList" :key="item.id" :label="`${item.processRouteCode} - ${item.productName}`" :value="item.id" />
          </el-select>
        </el-col>
        <el-col :span="6">
          <el-button-group>
            <el-button :type="isPlaying ? 'warning' : 'primary'" @click="togglePlay">
              <el-icon v-if="isPlaying"><VideoPause /></el-icon>
              <el-icon v-else><VideoPlay /></el-icon>
              {{ isPlaying ? '暂停' : '播放' }}
            </el-button>
            <el-button @click="handleReset">
              <el-icon><RefreshRight /></el-icon>
              é‡ç½®
            </el-button>
          </el-button-group>
        </el-col>
        <el-col :span="6">
          <div class="speed-control">
            <span>速度:</span>
            <el-radio-group v-model="speed" size="small">
              <el-radio-button label="0.5">慢</el-radio-button>
              <el-radio-button label="1">正常</el-radio-button>
              <el-radio-button label="2">å¿«</el-radio-button>
            </el-radio-group>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="progress-info">
            <span>进度:</span>
            <el-progress :percentage="productionProgress" :stroke-width="8" style="width: 120px" />
          </div>
        </el-col>
      </el-row>
    </div>
    <div class="factory-container" v-loading="loading">
      <div class="factory-scene">
        <svg class="factory-svg" viewBox="0 0 1100 500" preserveAspectRatio="xMidYMid meet">
          <defs>
            <!-- æ¸å˜å®šä¹‰ -->
            <linearGradient id="beltGradient" x1="0%" y1="0%" x2="100%" y2="0%">
              <stop offset="0%" stop-color="#409eff" stop-opacity="0.8" />
              <stop offset="50%" stop-color="#66b1ff" stop-opacity="0.6" />
              <stop offset="100%" stop-color="#409eff" stop-opacity="0.8" />
            </linearGradient>
            <linearGradient id="stationGradient" x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stop-color="#f8f9fa" />
              <stop offset="100%" stop-color="#e9ecef" />
            </linearGradient>
            <linearGradient id="activeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stop-color="#ecf5ff" />
              <stop offset="100%" stop-color="#d9ecff" />
            </linearGradient>
            <filter id="stationShadow" x="-20%" y="-20%" width="140%" height="140%">
              <feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity="0.15" />
            </filter>
            <filter id="glowEffect" x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation="4" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>
          <!-- èƒŒæ™¯åœ°æ¿ -->
          <rect x="0" y="380" width="1100" height="120" fill="#f5f7fa" />
          <rect x="0" y="380" width="1100" height="2" fill="#e4e7ed" />
          <!-- å±¥å¸¦ -->
          <g class="conveyor-system">
            <!-- å±¥å¸¦ä¸»ä½“ -->
            <rect x="40" y="320" width="1020" height="60" rx="8" fill="url(#beltGradient)" opacity="0.3" />
            <!-- å±¥å¸¦è¾¹ç¼˜ -->
            <rect x="40" y="320" width="1020" height="4" rx="2" fill="#409eff" opacity="0.5" />
            <rect x="40" y="376" width="1020" height="4" rx="2" fill="#409eff" opacity="0.5" />
            <!-- æµåŠ¨çº¿æ¡ -->
            <path class="belt-flow" d="M50 350 H1050" stroke="#fff" stroke-width="3" stroke-dasharray="15 20" stroke-linecap="round" opacity="0.8" />
            <!-- å±¥å¸¦æ»šè½® -->
            <circle v-for="i in 12" :key="'roller'+i" :cx="50 + i * 85" cy="350" r="8" fill="#fff" opacity="0.6" />
          </g>
          <!-- å…¥åº“区 -->
          <g class="input-area" @click="showInputDialog">
            <rect x="20" y="200" width="80" height="80" rx="12" fill="#f0f9eb" stroke="#67c23a" stroke-width="2" filter="url(#stationShadow)" />
            <text x="60" y="245" text-anchor="middle" font-size="14" fill="#67c23a" font-weight="600">原材料</text>
            <text x="60" y="265" text-anchor="middle" font-size="12" fill="#67c23a">入库</text>
            <!-- å…¥åº“图标 -->
            <g transform="translate(45, 215)">
              <rect width="30" height="20" rx="4" fill="#67c23a" opacity="0.8" />
              <path d="M15 -8 L15 24" stroke="#67c23a" stroke-width="3" stroke-dasharray="4 4" />
            </g>
          </g>
          <!-- å‡ºåº“区 -->
          <g class="output-area" @click="showOutputDialog">
            <rect x="1000" y="200" width="80" height="80" rx="12" fill="#ecf5ff" stroke="#409eff" stroke-width="2" filter="url(#stationShadow)" />
            <text x="1040" y="245" text-anchor="middle" font-size="14" fill="#409eff" font-weight="600">成品</text>
            <text x="1040" y="265" text-anchor="middle" font-size="12" fill="#409eff">出库</text>
            <!-- å‡ºåº“图标 -->
            <g transform="translate(1015, 215)">
              <rect width="30" height="20" rx="4" fill="#409eff" opacity="0.8" />
              <path d="M15 28 L15 0" stroke="#409eff" stroke-width="3" stroke-dasharray="4 4" />
              <path d="M8 -5 L15 -12 L22 -5" stroke="#409eff" stroke-width="2" fill="none" />
            </g>
          </g>
          <!-- å·¥åºå·¥ä½œç«™ -->
          <g
            v-for="(process, index) in processList"
            :key="process.id || index"
            class="work-station"
            :class="{ 'is-active': currentProcessIndex === index, 'is-completed': completedIndex > index }"
            :transform="`translate(${getStationX(index)}, 0)`"
            @click="showProcessDetail(process)"
          >
            <!-- æœºå™¨å¤–壳 -->
            <rect x="0" y="80" width="120" height="140" rx="16" :fill="currentProcessIndex === index ? 'url(#activeGradient)' : 'url(#stationGradient)'" :stroke="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#409eff' : '#e4e7ed'" stroke-width="2" filter="url(#stationShadow)" />
            <!-- å·¥åºåºå· -->
            <circle cx="60" cy="60" r="24" :fill="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#409eff' : '#909399'" />
            <text x="60" y="65" text-anchor="middle" font-size="14" fill="#fff" font-weight="bold">{{ index + 1 }}</text>
            <!-- å·¥åºåç§° -->
            <text x="60" y="110" text-anchor="middle" font-size="13" :fill="currentProcessIndex === index ? '#409eff' : '#303133'" font-weight="600" class="station-name">
              {{ truncateText(process.technologyOperationName || process.operationName, 8) }}
            </text>
            <!-- äº§å“ä¿¡æ¯ -->
            <text x="60" y="130" text-anchor="middle" font-size="10" fill="#909399">
              {{ truncateText(process.productName, 10) || '无产品' }}
            </text>
            <!-- çŠ¶æ€æ ‡ç­¾ -->
            <g transform="translate(20, 145)">
              <rect width="80" height="20" rx="4" :fill="process.isQuality ? '#fdf6ec' : '#f5f7fa'" />
              <text x="40" y="14" text-anchor="middle" font-size="10" :fill="process.isQuality ? '#e6a23c' : '#909399'">
                {{ process.isQuality ? '质检点' : '加工' }}
              </text>
            </g>
            <!-- æŠ•料口 -->
            <g class="feed-point" :transform="`translate(50, 218)`">
              <circle r="10" fill="#fff" stroke="#409eff" stroke-width="2" :class="{ 'is-feeding': feedingIndex === index }" />
              <text y="4" text-anchor="middle" font-size="10" fill="#409eff">投</text>
            </g>
            <!-- çŠ¶æ€æŒ‡ç¤ºç¯ -->
            <circle cx="110" cy="90" r="6" :fill="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#e6a23c' : '#c0c4cc'" :class="{ 'is-pulsing': currentProcessIndex === index }" />
            <!-- æŠ•料动画小球 -->
            <g v-if="feedingIndex === index" class="feed-ball">
              <circle cx="60" cy="200" r="8" fill="#67c23a">
                <animate attributeName="cy" values="200;280" dur="0.3s" fill="freeze" />
                <animate attributeName="opacity" values="1;0" dur="0.3s" fill="freeze" />
              </circle>
            </g>
          </g>
          <!-- ç§»åŠ¨çš„äº§å“ç®±å­ -->
          <g class="product-boxes">
            <g v-for="(box, bIndex) in visibleBoxes" :key="bIndex" class="product-box" :transform="`translate(${box.x}, ${box.y})`">
              <!-- ç®±å­ä¸»ä½“ - æ”¾å¤§å°ºå¯¸ -->
              <rect x="0" y="0" width="70" height="45" rx="8" :fill="box.color" opacity="0.95" />
              <rect x="0" y="15" width="70" height="3" fill="#fff" opacity="0.3" />
              <!-- äº§å“åç§° -->
              <text x="35" y="35" text-anchor="middle" font-size="12" fill="#fff" font-weight="600">{{ box.label }}</text>
              <!-- æ‚¬åœæç¤ºæ¡† -->
              <title>{{ box.fullName || box.label }}</title>
            </g>
          </g>
          <!-- æŠ•料动画:原料从工序节点落下到产品上 -->
          <g v-if="showFeedingAnimation && feedingProcessIndex >= 0" class="feeding-animation">
            <!-- æŠ•入的原料小球 -->
            <circle
              :cx="getStationX(feedingProcessIndex) + 60"
              :cy="feedingAnimY"
              r="12"
              fill="#67c23a"
              opacity="0.9"
            >
              <animate
                attributeName="cy"
                :values="`${feedingStartY};${feedingEndY}`"
                :dur="`${0.4 / parseFloat(speed)}s`"
                fill="freeze"
              />
              <animate
                attributeName="opacity"
                values="1;0.6"
                :dur="`${0.4 / parseFloat(speed)}s`"
                fill="freeze"
              />
            </circle>
            <!-- åŽŸæ–™åç§° -->
            <text
              :x="getStationX(feedingProcessIndex) + 60"
              :y="feedingAnimY - 18"
              text-anchor="middle"
              font-size="10"
              fill="#67c23a"
              font-weight="600"
              opacity="0.9"
            >
              {{ truncateText(feedingMaterialName, 5) || '原料' }}
            </text>
          </g>
          <!-- äº§å‡ºåŠ¨ç”»ï¼šåŠ å·¥å®ŒæˆåŽçš„é—ªå…‰æ•ˆæžœ -->
          <g v-if="showOutputAnimation && outputProcessIndex >= 0" class="output-animation">
            <circle
              :cx="getStationX(outputProcessIndex) + 60"
              cy="285"
              r="30"
              fill="none"
              stroke="#409eff"
              stroke-width="3"
              opacity="0.8"
            >
              <animate
                attributeName="r"
                values="20;40"
                dur="0.5s"
                fill="freeze"
              />
              <animate
                attributeName="opacity"
                values="0.8;0"
                dur="0.5s"
                fill="freeze"
              />
            </circle>
            <!-- äº§å‡ºåç§°æç¤º -->
            <text
              :x="getStationX(outputProcessIndex) + 60"
              y="340"
              text-anchor="middle"
              font-size="11"
              fill="#409eff"
              font-weight="600"
            >
              äº§å‡º: {{ truncateText(outputProductName, 6) || '产品' }}
            </text>
          </g>
          <!-- è¿›åº¦æŒ‡ç¤ºçº¿ -->
          <path class="progress-line" :d="getProgressPath()" stroke="#67c23a" stroke-width="3" fill="none" opacity="0.6" />
          <!-- è¯´æ˜Žæ–‡å­—区域 -->
          <g class="info-text" v-if="currentProcess">
            <rect x="200" y="10" width="300" height="50" rx="8" fill="#ecf5ff" stroke="#409eff" stroke-width="1" opacity="0.9" />
            <text x="350" y="35" text-anchor="middle" font-size="14" fill="#409eff" font-weight="600">
              å½“前工序:{{ currentProcess.technologyOperationName || currentProcess.operationName }}
            </text>
            <text x="350" y="50" text-anchor="middle" font-size="11" fill="#606266">
              {{ feedingIndex >= 0 ? '正在投料...' : '产品流转中...' }}
            </text>
          </g>
        </svg>
      </div>
      <div class="factory-sidebar">
        <el-card class="info-card" shadow="hover">
          <template #header>
            <span>生产线状态</span>
          </template>
          <div class="status-info">
            <div class="status-item">
              <span class="status-label">工序总数</span>
              <span class="status-value">{{ processList.length }}</span>
            </div>
            <div class="status-item">
              <span class="status-label">已完成</span>
              <span class="status-value">{{ completedIndex }} / {{ processList.length }}</span>
            </div>
            <div class="status-item">
              <span class="status-label">当前工序</span>
              <span class="status-value highlight">{{ currentProcess?.technologyOperationName || currentProcess?.operationName || '-' }}</span>
            </div>
            <div class="status-item">
              <span class="status-label">生产状态</span>
              <el-tag :type="isPlaying ? 'success' : 'info'" size="small">{{ isPlaying ? '运行中' : '已暂停' }}</el-tag>
            </div>
          </div>
        </el-card>
        <el-card class="legend-card" shadow="hover">
          <template #header>
            <span>状态说明</span>
          </template>
          <div class="legend-item">
            <span class="legend-dot completed"></span>
            <span class="legend-text">已完成</span>
          </div>
          <div class="legend-item">
            <span class="legend-dot current"></span>
            <span class="legend-text">当前工序</span>
          </div>
          <div class="legend-item">
            <span class="legend-dot pending"></span>
            <span class="legend-text">待加工</span>
          </div>
          <div class="legend-item">
            <span class="legend-dot quality"></span>
            <span class="legend-text">质检点</span>
          </div>
        </el-card>
        <el-card class="tips-card" shadow="hover">
          <template #header>
            <span>操作提示</span>
          </template>
          <div class="tips-content">
            <p>点击工序节点查看详细信息</p>
            <p>点击"原材料入库"查看投料清单</p>
            <p>点击"成品出库"查看产出信息</p>
            <p>使用控制面板调节演示速度</p>
          </div>
        </el-card>
      </div>
    </div>
    <!-- å·¥åºè¯¦æƒ…对话框 -->
    <el-dialog v-model="processDialogVisible" :title="`工序详情 - ${selectedProcess?.technologyOperationName || selectedProcess?.operationName}`" width="650px">
      <el-descriptions :column="2" border size="small" v-if="selectedProcess">
        <el-descriptions-item label="工序名称">{{ selectedProcess.technologyOperationName || selectedProcess.operationName }}</el-descriptions-item>
        <el-descriptions-item label="工序序号">{{ selectedProcess.dragSort || selectedProcessIndex + 1 }}</el-descriptions-item>
        <el-descriptions-item label="产品名称">{{ selectedProcess.productName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="规格型号">{{ selectedProcess.model || '-' }}</el-descriptions-item>
        <el-descriptions-item label="单位">{{ selectedProcess.unit || '-' }}</el-descriptions-item>
        <el-descriptions-item label="计费类型">
          <el-tag :type="selectedProcess.type === 1 ? 'primary' : 'success'" size="small">
            {{ selectedProcess.type === 1 ? '计件' : '计时' }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="是否质检">
          <el-tag :type="selectedProcess.isQuality ? 'warning' : 'info'" size="small">{{ selectedProcess.isQuality ? '是' : '否' }}</el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="是否生产">
          <el-tag :type="selectedProcess.isProduction ? 'success' : 'info'" size="small">{{ selectedProcess.isProduction ? '是' : '否' }}</el-tag>
        </el-descriptions-item>
      </el-descriptions>
      <!-- æŠ•入物料 -->
      <div class="material-section" v-if="inputMaterials.length > 0">
        <div class="section-header">
          <el-icon style="color: #67c23a;"><Bottom /></el-icon>
          <h4>需要投入的物料({{ inputMaterials.length }} ç§ï¼‰</h4>
        </div>
        <el-table :data="inputMaterials" border size="small" max-height="200">
          <el-table-column prop="productName" label="物料名称" min-width="120" />
          <el-table-column prop="model" label="规格型号" min-width="100" />
          <el-table-column prop="unitQuantity" label="单位用量" width="90" align="right">
            <template #default="{ row }">
              <span class="quantity-value">{{ row.unitQuantity }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="unit" label="单位" width="70" align="center" />
        </el-table>
        <div class="material-summary">
          <el-tag type="success" size="small" effect="plain">
            ç”Ÿäº§1个单位产品需要投入以上 {{ inputMaterials.length }} ç§ç‰©æ–™
          </el-tag>
        </div>
      </div>
      <div class="material-section empty-section" v-else>
        <el-empty description="该工序暂无需要投入的物料" :image-size="60" />
      </div>
      <!-- äº§å‡ºäº§å“ -->
      <div class="material-section" v-if="outputProduct">
        <div class="section-header">
          <el-icon style="color: #409eff;"><Top /></el-icon>
          <h4>产出产品(本工序产出)</h4>
        </div>
        <el-descriptions :column="2" border size="small">
          <el-descriptions-item label="产品名称">{{ outputProduct.productName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="规格型号">{{ outputProduct.model || '-' }}</el-descriptions-item>
          <el-descriptions-item label="单位">{{ outputProduct.unit || '-' }}</el-descriptions-item>
          <el-descriptions-item label="产出类型">{{ selectedProcess?.isProduction ? '生产产出' : '加工产出' }}</el-descriptions-item>
        </el-descriptions>
      </div>
      <!-- ä¸Šä¸‹æ¸¸å·¥åº -->
      <div class="material-section" v-if="selectedProcessIndex >= 0">
        <div class="section-header">
          <el-icon style="color: #909399;"><Sort /></el-icon>
          <h4>工序流转</h4>
        </div>
        <div class="process-flow">
          <div class="flow-item" v-if="prevProcess">
            <span class="flow-label">上一道工序</span>
            <el-tag type="info" size="small">{{ prevProcess.technologyOperationName || prevProcess.operationName }}</el-tag>
          </div>
          <div class="flow-item current">
            <span class="flow-label">当前工序</span>
            <el-tag type="primary">{{ selectedProcess?.technologyOperationName || selectedProcess?.operationName }}</el-tag>
          </div>
          <div class="flow-item" v-if="nextProcess">
            <span class="flow-label">下一道工序</span>
            <el-tag type="info" size="small">{{ nextProcess.technologyOperationName || nextProcess.operationName }}</el-tag>
          </div>
        </div>
      </div>
      <template #footer>
        <el-button @click="processDialogVisible = false">关闭</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { VideoPlay, VideoPause, RefreshRight, Bottom, Top, Sort } from '@element-plus/icons-vue'
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
import { queryList } from '@/api/productionManagement/productStructure.js'
const router = useRouter()
const loading = ref(false)
const routeList = ref([])
const selectedRouteId = ref(null)
const selectedBomId = ref(null)
const processList = ref([])
const bomData = ref([])
const isPlaying = ref(false)
const speed = ref('1')
const currentProcessIndex = ref(-1)
const completedIndex = ref(0)
const feedingIndex = ref(-1)
const processDialogVisible = ref(false)
const selectedProcess = ref(null)
const selectedProcessIndex = ref(-1)
const boxPosition = ref(0)
const animationTimer = ref(null)
const boxColors = ['#ffb15f', '#28d9cd', '#8b5cf6', '#409eff', '#67c23a']
// æŠ•料动画相关
const showFeedingAnimation = ref(false)
const feedingProcessIndex = ref(-1)
const feedingMaterialName = ref('')
const feedingAnimY = ref(200)
const feedingStartY = 200
const feedingEndY = 285
// äº§å‡ºåŠ¨ç”»ç›¸å…³
const showOutputAnimation = ref(false)
const outputProcessIndex = ref(-1)
const outputProductName = ref('')
// ä¸Šä¸€æ¬¡å®Œæˆçš„工序索引(用于判断是否需要触发产出动画)
const lastCompletedIndex = ref(-1)
const productionProgress = computed(() => {
  if (processList.value.length === 0) return 0
  return Math.round((completedIndex.value / processList.value.length) * 100)
})
const currentProcess = computed(() => {
  if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
    return processList.value[currentProcessIndex.value]
  }
  return null
})
const processMaterials = computed(() => {
  if (!selectedProcess.value) return []
  const processName = selectedProcess.value.technologyOperationName || selectedProcess.value.operationName
  const materials = []
  const traverse = (nodes) => {
    for (const node of nodes) {
      if (node.processName === processName) {
        materials.push(node)
      }
      if (node.children) traverse(node.children)
    }
  }
  traverse(bomData.value)
  return materials
})
// æŠ•入物料(需要投入的物料 - ä»ŽBOM中匹配工序)
const inputMaterials = computed(() => {
  if (!selectedProcess.value) return []
  const processId = selectedProcess.value.technologyOperationId || selectedProcess.value.operationId
  const processName = selectedProcess.value.technologyOperationName || selectedProcess.value.operationName
  const materials = []
  const traverse = (nodes) => {
    for (const node of nodes) {
      // ä¼˜å…ˆç”¨ID匹配,其次用名称匹配
      const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
      const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
      const isMatch = (processId && nodeProcessId === processId) ||
                      (processName && nodeProcessName === processName)
      if (isMatch) {
        materials.push({
          productName: node.productName || '未命名物料',
          model: node.model || '-',
          unitQuantity: node.unitQuantity || 1,
          unit: node.unit || '-',
          processName: nodeProcessName || '-'
        })
      }
      if (node.children && node.children.length > 0) {
        traverse(node.children)
      }
    }
  }
  traverse(bomData.value)
  return materials
})
// äº§å‡ºäº§å“ï¼ˆå½“前工序关联的产品信息)
const outputProduct = computed(() => {
  if (!selectedProcess.value) return null
  return {
    productName: selectedProcess.value.productName || null,
    model: selectedProcess.value.model || null,
    unit: selectedProcess.value.unit || null
  }
})
// ä¸Šä¸€é“工序
const prevProcess = computed(() => {
  if (selectedProcessIndex.value <= 0) return null
  return processList.value[selectedProcessIndex.value - 1]
})
// ä¸‹ä¸€é“工序
const nextProcess = computed(() => {
  if (selectedProcessIndex.value < 0 || selectedProcessIndex.value >= processList.value.length - 1) return null
  return processList.value[selectedProcessIndex.value + 1]
})
const visibleBoxes = computed(() => {
  const boxes = []
  const baseX = 60 + boxPosition.value
  // è®¡ç®—产品当前所在的工序索引
  const totalWidth = 900
  const progressRatio = Math.min(1, boxPosition.value / totalWidth)
  const currentStep = Math.floor(progressRatio * processList.value.length)
  // å½“前工序
  const currentProc = processList.value[currentStep] || null
  // ä¸Šä¸€é“工序(已完成)
  const prevProc = processList.value[currentStep - 1] || null
  // ç¡®å®šäº§å“åç§°
  let productLabel = '原料'
  let fullName = '原材料(待加工)'
  if (currentStep > 0 && prevProc) {
    // å·²ç»è¿‡äº†ä¸€äº›å·¥åºï¼Œæ˜¾ç¤ºä¸Šä¸€ä¸ªå·¥åºçš„产出
    productLabel = truncateText(prevProc.productName, 5) || '半成品'
    fullName = prevProc.productName || '半成品'
  }
  if (currentStep >= processList.value.length) {
    // å…¨éƒ¨å®Œæˆ
    const lastProc = processList.value[processList.value.length - 1]
    productLabel = truncateText(lastProc?.productName, 5) || '成品'
    fullName = lastProc?.productName || '成品'
  }
  if (baseX > 40) {
    boxes.push({
      x: baseX,
      y: 275, // è°ƒæ•´ä½ç½®ï¼Œè®©ç®±å­åœ¨å±¥å¸¦ä¸Š
      color: '#409eff',
      label: productLabel,
      fullName: fullName,
      processIndex: currentStep,
      isFeeding: feedingIndex.value === currentStep && currentStep < processList.value.length
    })
  }
  return boxes
})
// å½“前工序的投入物料名称
const currentInputMaterial = computed(() => {
  if (currentProcessIndex.value < 0 || currentProcessIndex.value >= processList.value.length) return null
  const process = processList.value[currentProcessIndex.value]
  if (!process) return null
  const processId = process.technologyOperationId || process.operationId
  const processName = process.technologyOperationName || process.operationName
  // ä»ŽBOM中找第一个匹配的物料
  let material = null
  const traverse = (nodes) => {
    for (const node of nodes) {
      const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
      const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
      if ((processId && nodeProcessId === processId) || (processName && nodeProcessName === processName)) {
        material = node
        return true
      }
      if (node.children && node.children.length > 0) {
        if (traverse(node.children)) return true
      }
    }
    return false
  }
  traverse(bomData.value)
  return material
})
const getStationX = (index) => {
  const totalStations = processList.value.length
  if (totalStations === 0) return 0
  const startX = 140
  const spacing = Math.min(180, (900 - startX) / totalStations)
  return startX + index * spacing
}
const truncateText = (text, maxLen) => {
  if (!text) return ''
  return text.length > maxLen ? text.substring(0, maxLen) + '..' : text
}
const getProgressPath = () => {
  if (completedIndex.value === 0) return ''
  const endX = getStationX(Math.min(completedIndex.value, processList.value.length) - 1) + 60
  return `M60 350 L${endX} 350`
}
const fetchRouteList = async () => {
  try {
    const res = await getRouteList({ current: 1, size: 100 })
    routeList.value = res?.data?.records || []
  } catch (error) {
    console.error('获取工艺路线列表失败:', error)
  }
}
const fetchProcessList = async () => {
  if (!selectedRouteId.value) return
  loading.value = true
  try {
    const res = await findProcessRouteItemList({ routeId: selectedRouteId.value })
    processList.value = res?.data || []
    processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
    // èŽ·å–å…³è”çš„BOM数据
    const routeInfo = routeList.value.find(r => r.id === selectedRouteId.value)
    if (routeInfo?.bomId) {
      selectedBomId.value = routeInfo.bomId
      const bomRes = await queryList(routeInfo.bomId)
      bomData.value = bomRes?.data || []
    }
  } catch (error) {
    console.error('获取工艺路线数据失败:', error)
  } finally {
    loading.value = false
  }
}
const handleRouteChange = () => {
  handleReset()
  fetchProcessList()
}
const togglePlay = () => {
  isPlaying.value = !isPlaying.value
  if (isPlaying.value) {
    startAnimation()
  } else {
    stopAnimation()
  }
}
const handleReset = () => {
  stopAnimation()
  isPlaying.value = false
  currentProcessIndex.value = -1
  completedIndex.value = 0
  feedingIndex.value = -1
  boxPosition.value = 0
  showFeedingAnimation.value = false
  showOutputAnimation.value = false
  feedingProcessIndex.value = -1
  outputProcessIndex.value = -1
  lastCompletedIndex.value = -1
}
const startAnimation = () => {
  const speedFactor = parseFloat(speed.value)
  const interval = 2000 / speedFactor
  const moveStep = 20 / speedFactor
  animationTimer.value = setInterval(() => {
    if (!isPlaying.value) return
    // æ›´æ–°ç®±å­ä½ç½®
    boxPosition.value += moveStep
    // è®¡ç®—当前应该在哪个工序
    const totalWidth = 900
    const progressRatio = boxPosition.value / totalWidth
    const newIndex = Math.floor(progressRatio * processList.value.length)
    // è¿›å…¥æ–°å·¥åºæ—¶è§¦å‘投料动画
    if (newIndex !== currentProcessIndex.value && newIndex < processList.value.length) {
      // å…ˆè§¦å‘产出动画(上一道工序完成)
      if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
        const prevProcess = processList.value[currentProcessIndex.value]
        triggerOutputAnimation(currentProcessIndex.value, prevProcess?.productName || '产品')
      }
      currentProcessIndex.value = newIndex
      // è§¦å‘投料动画
      const process = processList.value[newIndex]
      const inputMaterial = getInputMaterialForProcess(process)
      triggerFeedingAnimation(newIndex, inputMaterial?.productName || '原料')
      feedingIndex.value = newIndex
      setTimeout(() => {
        feedingIndex.value = -1
      }, 500 / speedFactor)
    }
    // åˆ¤æ–­æ˜¯å¦å®Œæˆ
    if (boxPosition.value >= totalWidth) {
      // æœ€åŽä¸€ä¸ªå·¥åºå®Œæˆï¼Œè§¦å‘产出动画
      if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
        const lastProcess = processList.value[currentProcessIndex.value]
        triggerOutputAnimation(currentProcessIndex.value, lastProcess?.productName || '成品')
      }
      completedIndex.value = processList.value.length
      currentProcessIndex.value = -1
      // å¾ªçŽ¯æ’­æ”¾
      setTimeout(() => {
        boxPosition.value = 0
        completedIndex.value = 0
        currentProcessIndex.value = -1
        lastCompletedIndex.value = -1
        showFeedingAnimation.value = false
        showOutputAnimation.value = false
      }, 1500 / speedFactor)
    } else if (newIndex >= 0) {
      completedIndex.value = newIndex
    }
  }, interval)
}
// èŽ·å–å·¥åºéœ€è¦æŠ•å…¥çš„ç‰©æ–™
const getInputMaterialForProcess = (process) => {
  if (!process) return null
  const processId = process.technologyOperationId || process.operationId
  const processName = process.technologyOperationName || process.operationName
  let material = null
  const traverse = (nodes) => {
    for (const node of nodes) {
      const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
      const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
      if ((processId && nodeProcessId === processId) || (processName && nodeProcessName === processName)) {
        material = node
        return true
      }
      if (node.children && node.children.length > 0) {
        if (traverse(node.children)) return true
      }
    }
    return false
  }
  traverse(bomData.value)
  return material
}
// è§¦å‘投料动画
const triggerFeedingAnimation = (processIndex, materialName) => {
  showFeedingAnimation.value = true
  feedingProcessIndex.value = processIndex
  feedingMaterialName.value = materialName
  feedingAnimY.value = feedingStartY
  setTimeout(() => {
    showFeedingAnimation.value = false
  }, 600 / parseFloat(speed.value))
}
// è§¦å‘产出动画
const triggerOutputAnimation = (processIndex, productName) => {
  showOutputAnimation.value = true
  outputProcessIndex.value = processIndex
  outputProductName.value = productName
  setTimeout(() => {
    showOutputAnimation.value = false
  }, 800 / parseFloat(speed.value))
}
const stopAnimation = () => {
  if (animationTimer.value) {
    clearInterval(animationTimer.value)
    animationTimer.value = null
  }
}
const showProcessDetail = (process) => {
  selectedProcess.value = process
  // æŸ¥æ‰¾å·¥åºç´¢å¼•
  selectedProcessIndex.value = processList.value.findIndex(p =>
    p.id === process.id ||
    (p.technologyOperationName === process.technologyOperationName && p.productName === process.productName)
  )
  processDialogVisible.value = true
}
const showInputDialog = () => {
  // å¯ä»¥å±•示原材料入库信息
}
const showOutputDialog = () => {
  // å¯ä»¥å±•示成品出库信息
}
const goBack = () => {
  router.push('/productionManagement/teachingDemo')
}
watch(speed, () => {
  if (isPlaying.value) {
    stopAnimation()
    startAnimation()
  }
})
onMounted(() => {
  fetchRouteList()
})
onUnmounted(() => {
  stopAnimation()
})
</script>
<style scoped lang="scss">
.factory-demo {
  .control-panel {
    background: #fff;
    padding: 20px;
    border-radius: 12px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
    margin-bottom: 20px;
    .speed-control {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .progress-info {
      display: flex;
      align-items: center;
      gap: 8px;
    }
  }
  .factory-container {
    display: flex;
    gap: 20px;
    min-height: 500px;
    .factory-scene {
      flex: 1;
      background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
      border-radius: 16px;
      padding: 20px;
      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
      overflow: hidden;
      .factory-svg {
        width: 100%;
        height: auto;
        min-height: 450px;
        .conveyor-system {
          .belt-flow {
            animation: beltFlow 0.8s linear infinite;
          }
        }
        .work-station {
          cursor: pointer;
          &:hover {
            filter: brightness(1.1) drop-shadow(0 0 12px rgba(64, 158, 255, 0.4));
          }
          &.is-active {
            .station-name {
              animation: textGlow 1s ease-in-out infinite;
            }
          }
          .feed-point {
            transition: all 0.3s ease;
            &.is-feeding {
              animation: feedPulse 0.3s ease-out;
            }
          }
          .is-pulsing {
            animation: statusPulse 1s ease-in-out infinite;
          }
        }
        .product-box {
          transition: transform 0.1s linear;
        }
        .product-boxes {
          animation: boxesMove calc(8s / var(--speed, 1)) linear infinite;
        }
        .info-text {
          animation: fadeInOut 0.5s ease;
        }
      }
    }
    .factory-sidebar {
      width: 280px;
      display: flex;
      flex-direction: column;
      gap: 16px;
      .info-card {
        .status-info {
          .status-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 0;
            border-bottom: 1px solid #f0f0f0;
            &:last-child {
              border-bottom: none;
            }
            .status-label {
              color: #909399;
              font-size: 13px;
            }
            .status-value {
              color: #303133;
              font-weight: 600;
              &.highlight {
                color: #409eff;
              }
            }
          }
        }
      }
      .legend-card {
        .legend-item {
          display: flex;
          align-items: center;
          gap: 8px;
          margin-bottom: 8px;
          .legend-dot {
            width: 12px;
            height: 12px;
            border-radius: 50%;
            &.completed {
              background: #67c23a;
            }
            &.current {
              background: #e6a23c;
              animation: legendPulse 1s ease-in-out infinite;
            }
            &.pending {
              background: #c0c4cc;
            }
            &.quality {
              background: #fdf6ec;
              border: 2px solid #e6a23c;
            }
          }
          .legend-text {
            font-size: 12px;
            color: #666;
          }
        }
      }
      .tips-card {
        .tips-content {
          p {
            font-size: 12px;
            color: #909399;
            margin-bottom: 8px;
            line-height: 1.6;
            &:before {
              content: '• ';
              color: #409eff;
            }
          }
        }
      }
    }
  }
  .material-section {
    margin-top: 16px;
    .section-header {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 12px;
      h4 {
        color: #303133;
        font-size: 14px;
        margin: 0;
        font-weight: 600;
      }
    }
    &.empty-section {
      margin-top: 12px;
    }
    .quantity-value {
      font-weight: 600;
      color: #67c23a;
    }
    .material-summary {
      margin-top: 8px;
    }
    .process-flow {
      display: flex;
      align-items: center;
      gap: 16px;
      flex-wrap: wrap;
      .flow-item {
        display: flex;
        align-items: center;
        gap: 8px;
        .flow-label {
          font-size: 12px;
          color: #909399;
        }
        &.current {
          .flow-label {
            color: #409eff;
            font-weight: 600;
          }
        }
      }
    }
  }
}
@keyframes beltFlow {
  to {
    stroke-dashoffset: -35;
  }
}
@keyframes statusPulse {
  0%, 100% {
    opacity: 0.6;
    transform: scale(1);
  }
  50% {
    opacity: 1;
    transform: scale(1.2);
  }
}
@keyframes feedPulse {
  0% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.3);
    fill: #67c23a;
  }
  100% {
    transform: scale(1);
  }
}
@keyframes textGlow {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.8;
  }
}
@keyframes legendPulse {
  0%, 100% {
    box-shadow: 0 0 4px rgba(230, 162, 60, 0.4);
  }
  50% {
    box-shadow: 0 0 8px rgba(230, 162, 60, 0.6);
  }
}
@keyframes fadeInOut {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
</style>
src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,535 @@
<template>
  <div class="app-container process-route-demo">
    <PageHeader :content="`工艺路线演示 - ${routeInfo.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="route-visualization">
        <div class="process-flow-container" v-if="processList.length > 0">
          <div class="flow-wrapper">
            <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"
              :show-progress="showProgress"
              :progress="getProcessProgress(process)"
              @click="handleProcessClick"
            />
          </div>
          <div class="flow-start-end">
            <div class="flow-node start">
              <el-icon><Goods /></el-icon>
              <span>原材料</span>
            </div>
            <div class="flow-connector">
              <svg width="60" height="24">
                <line x1="0" y1="12" x2="50" y2="12" stroke="#67c23a" stroke-width="2" stroke-dasharray="5,5" />
                <polygon points="50,8 60,12 50,16" fill="#67c23a" />
              </svg>
            </div>
            <div class="flow-node end">
              <el-icon><Box /></el-icon>
              <span>成品</span>
            </div>
          </div>
        </div>
        <el-empty v-else description="暂无工艺路线数据" />
        <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="activeProcess">
              <el-descriptions-item label="工序名称">{{ activeProcess.technologyOperationName || activeProcess.operationName }}</el-descriptions-item>
              <el-descriptions-item label="产品名称">{{ activeProcess.productName || '-' }}</el-descriptions-item>
              <el-descriptions-item label="规格型号">{{ activeProcess.model || '-' }}</el-descriptions-item>
              <el-descriptions-item label="单位">{{ activeProcess.unit || '-' }}</el-descriptions-item>
              <el-descriptions-item label="计费类型">{{ activeProcess.type === 1 ? '计件' : '计时' }}</el-descriptions-item>
              <el-descriptions-item label="是否质检">{{ activeProcess.isQuality ? '是' : '否' }}</el-descriptions-item>
              <el-descriptions-item label="是否生产">{{ activeProcess.isProduction ? '是' : '否' }}</el-descriptions-item>
              <el-descriptions-item label="工序序号">{{ activeProcess.dragSort || getProcessIndex(activeProcess) + 1 }}</el-descriptions-item>
            </el-descriptions>
            <div v-else class="empty-info">点击工序节点查看详细信息</div>
          </el-card>
          <el-card class="progress-card" shadow="hover">
            <template #header>
              <span>生产进度模拟</span>
            </template>
            <div class="progress-info">
              <div class="progress-stat">
                <span class="stat-label">已完成工序</span>
                <span class="stat-value">{{ completedProcesses.length }} / {{ processList.length }}</span>
              </div>
              <el-progress
                :percentage="processList.length > 0 ? (completedProcesses.length / processList.length) * 100 : 0"
                :stroke-width="10"
                status="success"
              />
            </div>
            <div class="progress-actions">
              <el-button type="primary" size="small" @click="simulateProgress">
                æ¨¡æ‹Ÿè¿›åº¦
              </el-button>
              <el-button size="small" @click="resetProgress">
                é‡ç½®
              </el-button>
            </div>
          </el-card>
          <el-card class="legend-card" shadow="hover">
            <template #header>
              <span>状态说明</span>
            </template>
            <div class="legend-item">
              <span class="legend-icon default"></span>
              <span class="legend-text">待加工</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon current"></span>
              <span class="legend-text">当前工序</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon completed"></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, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Goods, Box } from '@element-plus/icons-vue'
import ProcessNode from './components/ProcessNode.vue'
import DemoControls from './components/DemoControls.vue'
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const processList = ref([])
const controlsRef = ref(null)
const animationSpeed = ref(1)
const isAnimating = ref(false)
const activeProcessId = ref(null)
const activeProcess = ref(null)
const currentProcessId = ref(null)
const completedProcesses = ref([])
const showProgress = ref(false)
const playTimer = ref(null)
const progressTimer = ref(null)
const routeId = computed(() => route.query.id)
const routeInfo = computed(() => ({
  processRouteCode: route.query.processRouteCode || '',
  productName: route.query.productName || '',
  model: route.query.model || '',
  bomId: route.query.bomId || ''
}))
const demoSteps = [
  { title: '工序概览', description: '查看全部工序' },
  { title: '工序顺序', description: '了解流程' },
  { title: '工序详情', description: '查看参数' },
  { title: '进度模拟', description: '生产流程' },
  { title: '完成状态', description: '生产完成' }
]
const demoExplanations = [
  {
    title: '第1步:工序概览',
    content: '工艺路线展示了产品从原材料到成品需要经过的所有工序。每个节点代表一道独立的加工步骤。'
  },
  {
    title: '第2步:工序顺序',
    content: '箭头表示工序的执行顺序。产品按照箭头方向依次经过每道工序,直到最终完成。'
  },
  {
    title: '第3步:工序详情',
    content: '点击工序节点可以查看详细信息,包括产品名称、规格、计费类型、是否需要质检等。'
  },
  {
    title: '第4步:进度模拟',
    content: '点击"模拟进度"可以观看产品在各工序间流转的过程。绿色表示已完成,黄色表示当前工序。'
  },
  {
    title: '第5步:生产完成',
    content: '当所有工序都完成后,产品就生产出来了。实际生产中,每道工序可能会有更详细的参数和操作要求。'
  }
]
const fetchProcessList = async () => {
  if (!routeId.value) return
  loading.value = true
  try {
    const res = await findProcessRouteItemList({ routeId: routeId.value })
    processList.value = res?.data || []
    // æŒ‰åºå·æŽ’序
    processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
  } catch (error) {
    console.error('获取工艺路线数据失败:', error)
  } finally {
    loading.value = false
  }
}
const goBack = () => {
  router.push('/productionManagement/teachingDemo')
}
const handleProcessClick = (process) => {
  activeProcess.value = process
  activeProcessId.value = process.id
}
const getProcessIndex = (process) => {
  return processList.value.findIndex(p => p.id === process.id)
}
const getProcessProgress = (process) => {
  if (completedProcesses.value.includes(process.id)) {
    return 100
  }
  if (currentProcessId.value === process.id) {
    return 50
  }
  return 0
}
const handlePlay = () => {
  isAnimating.value = true
  runAnimation()
}
const handlePause = () => {
  isAnimating.value = false
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
}
const handleReset = () => {
  isAnimating.value = false
  activeProcessId.value = null
  activeProcess.value = null
  currentProcessId.value = null
  completedProcesses.value = []
  showProgress.value = false
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
  if (progressTimer.value) {
    clearInterval(progressTimer.value)
  }
}
const handleStepChange = (step) => {
  switch (step) {
    case 0:
      // å·¥åºæ¦‚览
      break
    case 1:
      // å·¥åºé¡ºåº
      if (processList.value.length > 0) {
        activeProcess.value = processList.value[0]
        activeProcessId.value = processList.value[0].id
      }
      break
    case 2:
      // å·¥åºè¯¦æƒ…
      break
    case 3:
      // è¿›åº¦æ¨¡æ‹Ÿ
      simulateProgress()
      break
    case 4:
      // å®ŒæˆçŠ¶æ€
      completedProcesses.value = processList.value.map(p => p.id)
      currentProcessId.value = null
      break
  }
}
const handleSpeedChange = (speed) => {
  animationSpeed.value = speed
}
const simulateProgress = () => {
  showProgress.value = true
  completedProcesses.value = []
  currentProcessId.value = null
  let index = 0
  const interval = 1500 / animationSpeed.value
  progressTimer.value = setInterval(() => {
    if (index >= processList.value.length) {
      clearInterval(progressTimer.value)
      currentProcessId.value = null
      isAnimating.value = false
      controlsRef.value?.setIsPlaying(false)
      return
    }
    if (index > 0) {
      completedProcesses.value.push(processList.value[index - 1].id)
    }
    currentProcessId.value = processList.value[index].id
    activeProcess.value = processList.value[index]
    activeProcessId.value = processList.value[index].id
    index++
  }, interval)
}
const resetProgress = () => {
  if (progressTimer.value) {
    clearInterval(progressTimer.value)
  }
  completedProcesses.value = []
  currentProcessId.value = null
  showProgress.value = false
}
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(() => {
  fetchProcessList()
})
onUnmounted(() => {
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
  if (progressTimer.value) {
    clearInterval(progressTimer.value)
  }
})
</script>
<style scoped lang="scss">
.process-route-demo {
  .demo-container {
    background: #f5f7fa;
    padding: 20px;
    border-radius: 12px;
    min-height: 400px;
    .route-visualization {
      display: flex;
      gap: 20px;
      .process-flow-container {
        flex: 1;
        background: #fff;
        padding: 30px;
        border-radius: 12px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
        .flow-wrapper {
          display: flex;
          align-items: center;
          gap: 0;
          padding: 20px 0;
          overflow-x: auto;
          min-height: 200px;
        }
        .flow-start-end {
          display: flex;
          align-items: center;
          justify-content: center;
          margin-top: 30px;
          padding-top: 20px;
          border-top: 1px dashed #e4e7ed;
          .flow-node {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px;
            padding: 16px 24px;
            border-radius: 12px;
            &.start {
              background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%);
              border: 2px solid #67c23a;
              .el-icon {
                color: #67c23a;
                font-size: 24px;
              }
              span {
                color: #67c23a;
                font-weight: 600;
              }
            }
            &.end {
              background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
              border: 2px solid #409eff;
              .el-icon {
                color: #409eff;
                font-size: 24px;
              }
              span {
                color: #409eff;
                font-weight: 600;
              }
            }
          }
          .flow-connector {
            margin: 0 20px;
          }
        }
      }
      .visualization-sidebar {
        width: 300px;
        display: flex;
        flex-direction: column;
        gap: 16px;
        .info-card,
        .progress-card,
        .legend-card {
          .empty-info {
            color: #909399;
            text-align: center;
            padding: 20px;
          }
        }
        .progress-card {
          .progress-info {
            margin-bottom: 16px;
            .progress-stat {
              display: flex;
              justify-content: space-between;
              margin-bottom: 8px;
              .stat-label {
                color: #666;
                font-size: 14px;
              }
              .stat-value {
                color: #409eff;
                font-weight: 600;
              }
            }
          }
          .progress-actions {
            display: flex;
            gap: 8px;
          }
        }
        .legend-card {
          .legend-item {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 8px;
            .legend-icon {
              width: 20px;
              height: 20px;
              border-radius: 4px;
              border: 2px solid;
              &.default {
                background: #fff;
                border-color: #e4e7ed;
              }
              &.current {
                background: linear-gradient(135deg, #fdf6ec 0%, #fff 100%);
                border-color: #e6a23c;
                box-shadow: 0 0 8px rgba(230, 162, 60, 0.4);
              }
              &.completed {
                background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%);
                border-color: #67c23a;
              }
              &.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>
src/views/productionManagement/teachingDemo/components/DemoControls.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
<template>
  <div class="demo-controls">
    <div class="control-buttons">
      <el-button-group>
        <el-button :type="isPlaying ? 'warning' : 'primary'" @click="togglePlay">
          <el-icon v-if="isPlaying"><VideoPause /></el-icon>
          <el-icon v-else><VideoPlay /></el-icon>
          {{ isPlaying ? '暂停' : '播放' }}
        </el-button>
        <el-button @click="handleReset">
          <el-icon><RefreshRight /></el-icon>
          é‡ç½®
        </el-button>
      </el-button-group>
      <div class="speed-control">
        <span class="speed-label">速度:</span>
        <el-radio-group v-model="speed" size="small" @change="handleSpeedChange">
          <el-radio-button label="0.5">慢速</el-radio-button>
          <el-radio-button label="1">正常</el-radio-button>
          <el-radio-button label="2">快速</el-radio-button>
        </el-radio-group>
      </div>
    </div>
    <div class="step-control" v-if="steps.length > 0">
      <span class="step-label">步骤:</span>
      <el-steps :active="currentStep" align-center>
        <el-step
          v-for="(step, index) in steps"
          :key="index"
          :title="step.title"
          :description="step.description"
          @click.native="handleStepClick(index)"
          class="clickable-step"
        />
      </el-steps>
    </div>
    <div class="explanation-box" v-if="currentExplanation">
      <el-alert
        :title="currentExplanation.title"
        type="success"
        :closable="false"
        show-icon
      >
        <template #default>
          {{ currentExplanation.content }}
        </template>
      </el-alert>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { VideoPlay, VideoPause, RefreshRight } from '@element-plus/icons-vue'
const props = defineProps({
  steps: {
    type: Array,
    default: () => []
  },
  explanations: {
    type: Array,
    default: () => []
  }
})
const emit = defineEmits(['play', 'pause', 'reset', 'stepChange', 'speedChange'])
const isPlaying = ref(false)
const speed = ref('1')
const currentStep = ref(0)
const currentExplanation = computed(() => {
  if (props.explanations && props.explanations[currentStep.value]) {
    return props.explanations[currentStep.value]
  }
  return null
})
const togglePlay = () => {
  isPlaying.value = !isPlaying.value
  if (isPlaying.value) {
    emit('play')
  } else {
    emit('pause')
  }
}
const handleReset = () => {
  isPlaying.value = false
  currentStep.value = 0
  emit('reset')
}
const handleStepClick = (index) => {
  currentStep.value = index
  emit('stepChange', index)
}
const handleSpeedChange = (val) => {
  emit('speedChange', parseFloat(val))
}
const nextStep = () => {
  if (currentStep.value < props.steps.length - 1) {
    currentStep.value++
    emit('stepChange', currentStep.value)
  }
}
const setStep = (index) => {
  currentStep.value = index
}
const setIsPlaying = (val) => {
  isPlaying.value = val
}
defineExpose({
  nextStep,
  setStep,
  setIsPlaying,
  currentStep
})
</script>
<style scoped lang="scss">
.demo-controls {
  background: #fff;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  margin-bottom: 20px;
  .control-buttons {
    display: flex;
    align-items: center;
    gap: 24px;
    margin-bottom: 20px;
    .speed-control {
      display: flex;
      align-items: center;
      gap: 8px;
      .speed-label {
        color: #666;
        font-size: 14px;
      }
    }
  }
  .step-control {
    margin-bottom: 20px;
    .step-label {
      display: block;
      color: #666;
      font-size: 14px;
      margin-bottom: 12px;
    }
    .clickable-step {
      cursor: pointer;
    }
  }
  .explanation-box {
    animation: fadeIn 0.3s ease;
  }
}
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
</style>
src/views/productionManagement/teachingDemo/components/ProcessNode.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,354 @@
<template>
  <div
    class="process-node"
    :class="{
      'is-active': active,
      'is-completed': completed,
      'is-current': current,
      'is-animating': animating
    }"
    :style="{ '--delay': delay + 'ms', '--speed': animationSpeed }"
  >
    <div class="node-badge">
      <span class="badge-number">{{ index + 1 }}</span>
    </div>
    <div class="node-card" @click="handleClick">
      <div class="node-header">
        <div class="node-icon">
          <el-icon><SetUp /></el-icon>
        </div>
        <div class="node-title">{{ process.technologyOperationName || process.operationName || '未命名工序' }}</div>
      </div>
      <div class="node-body">
        <div class="node-info-item" v-if="process.productName">
          <span class="info-label">产品:</span>
          <span class="info-value">{{ process.productName }}</span>
        </div>
        <div class="node-info-item" v-if="process.model">
          <span class="info-label">规格:</span>
          <span class="info-value">{{ process.model }}</span>
        </div>
        <div class="node-tags">
          <el-tag :type="process.type === 1 ? 'primary' : 'success'" size="small">
            {{ process.type === 1 ? '计件' : '计时' }}
          </el-tag>
          <el-tag v-if="process.isQuality" type="warning" size="small">质检</el-tag>
          <el-tag v-if="process.isProduction" type="info" size="small">生产</el-tag>
        </div>
      </div>
      <div class="node-progress" v-if="showProgress">
        <el-progress
          :percentage="progress"
          :status="completed ? 'success' : current ? '' : ''"
          :stroke-width="6"
        />
      </div>
    </div>
    <div class="node-arrow" v-if="showArrow">
      <svg width="40" height="24" viewBox="0 0 40 24">
        <defs>
          <marker
            id="arrowhead"
            markerWidth="10"
            markerHeight="7"
            refX="9"
            refY="3.5"
            orient="auto"
          >
            <polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
          </marker>
        </defs>
        <line
          x1="0"
          y1="12"
          x2="30"
          y2="12"
          stroke="#409eff"
          stroke-width="2"
          marker-end="url(#arrowhead)"
          :class="{ 'is-animating': animating }"
        />
        <circle
          v-if="animating"
          r="4"
          fill="#67c23a"
          class="flow-dot"
        >
          <animate
            attributeName="cx"
            values="0;30"
            :dur="`${1 / animationSpeed}s`"
            repeatCount="indefinite"
          />
          <animate
            attributeName="cy"
            values="12;12"
            dur="1s"
            repeatCount="indefinite"
          />
        </circle>
      </svg>
    </div>
  </div>
</template>
<script setup>
import { computed } from 'vue'
import { SetUp } from '@element-plus/icons-vue'
const props = defineProps({
  process: {
    type: Object,
    required: true
  },
  index: {
    type: Number,
    default: 0
  },
  delay: {
    type: Number,
    default: 0
  },
  animationSpeed: {
    type: Number,
    default: 1
  },
  active: {
    type: Boolean,
    default: false
  },
  completed: {
    type: Boolean,
    default: false
  },
  current: {
    type: Boolean,
    default: false
  },
  animating: {
    type: Boolean,
    default: false
  },
  showArrow: {
    type: Boolean,
    default: true
  },
  showProgress: {
    type: Boolean,
    default: false
  },
  progress: {
    type: Number,
    default: 0
  }
})
const emit = defineEmits(['click'])
const handleClick = () => {
  emit('click', props.process)
}
</script>
<style scoped lang="scss">
.process-node {
  display: flex;
  align-items: center;
  animation: nodeSlideIn calc(var(--delay) * var(--speed)) ease-out both;
  .node-badge {
    position: absolute;
    top: -12px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 1;
    .badge-number {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 28px;
      height: 28px;
      background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
      color: #fff;
      font-weight: bold;
      font-size: 14px;
      border-radius: 50%;
      box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
    }
  }
  .node-card {
    position: relative;
    width: 180px;
    background: #fff;
    border-radius: 12px;
    border: 2px solid #e4e7ed;
    padding: 20px 16px 16px;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
    &:hover {
      transform: translateY(-4px);
      box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
      border-color: #409eff;
    }
    .node-header {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 12px;
      .node-icon {
        width: 28px;
        height: 28px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
        border-radius: 6px;
        .el-icon {
          color: #fff;
          font-size: 16px;
        }
      }
      .node-title {
        font-weight: 600;
        color: #303133;
        font-size: 13px;
        flex: 1;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    }
    .node-body {
      .node-info-item {
        display: flex;
        font-size: 12px;
        margin-bottom: 4px;
        .info-label {
          color: #909399;
          margin-right: 4px;
        }
        .info-value {
          color: #606266;
          flex: 1;
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
        }
      }
      .node-tags {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        margin-top: 8px;
      }
    }
    .node-progress {
      margin-top: 12px;
    }
  }
  .node-arrow {
    margin: 0 8px;
    line {
      transition: stroke 0.3s;
      &.is-animating {
        animation: arrowGlow 0.5s ease-in-out infinite alternate;
      }
    }
    .flow-dot {
      filter: drop-shadow(0 0 4px rgba(103, 194, 58, 0.6));
    }
  }
  &.is-active {
    .node-card {
      border-color: #409eff;
      box-shadow: 0 4px 16px rgba(64, 158, 255, 0.3);
    }
  }
  &.is-completed {
    .node-card {
      border-color: #67c23a;
      background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%);
      .node-badge .badge-number {
        background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
      }
    }
  }
  &.is-current {
    .node-card {
      border-color: #e6a23c;
      animation: currentPulse 1.5s ease-in-out infinite;
    }
  }
  &.is-animating {
    .node-card {
      animation: cardPulse calc(1s * var(--speed)) ease-in-out infinite;
    }
  }
}
@keyframes nodeSlideIn {
  from {
    opacity: 0;
    transform: translateX(-30px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}
@keyframes currentPulse {
  0%, 100% {
    box-shadow: 0 4px 16px rgba(230, 162, 60, 0.3);
  }
  50% {
    box-shadow: 0 4px 24px rgba(230, 162, 60, 0.5);
  }
}
@keyframes cardPulse {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.02);
  }
}
@keyframes arrowGlow {
  from {
    stroke: #409eff;
    filter: drop-shadow(0 0 2px rgba(64, 158, 255, 0.4));
  }
  to {
    stroke: #66b1ff;
    filter: drop-shadow(0 0 6px rgba(64, 158, 255, 0.6));
  }
}
</style>
src/views/productionManagement/teachingDemo/components/TreeNode.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,423 @@
<template>
  <div
    class="tree-node"
    :class="{
      'is-root': isRoot,
      'is-expanded': expanded,
      'is-highlighted': highlighted,
      'is-active': active,
      'is-animating': animating
    }"
    :style="{ '--delay': delay + 'ms', '--speed': animationSpeed }"
  >
    <div class="node-content" @click="handleClick">
      <div class="node-icon">
        <el-icon v-if="hasChildren"><FolderOpened v-if="expanded" /><Folder v-else /></el-icon>
        <el-icon v-else><Document /></el-icon>
      </div>
      <div class="node-info">
        <div class="node-name">{{ node.productName || '未命名产品' }}</div>
        <div class="node-model" v-if="node.model">{{ node.model }}</div>
        <div class="node-meta">
          <span class="node-quantity">
            <el-icon><Goods /></el-icon>
            {{ node.unitQuantity || 1 }} {{ node.unit || '' }}
          </span>
          <span class="node-process" v-if="node.processName && !isRoot">
            <el-icon><Setting /></el-icon>
            {{ node.processName }}
          </span>
        </div>
      </div>
      <div class="node-expand-btn" v-if="hasChildren" @click.stop="toggleExpand">
        <el-icon>
          <ArrowDown v-if="expanded" />
          <ArrowRight v-else />
        </el-icon>
      </div>
    </div>
    <div class="node-children" v-if="hasChildren && expanded">
      <div class="children-container">
        <svg class="connection-lines" v-if="showLines">
          <path
            v-for="(child, index) in node.children"
            :key="child.tempId || child.id || index"
            :d="getLinePath(index)"
            class="connection-line"
            :class="{ 'is-animating': animating }"
            :style="{ '--line-delay': (delay + index * 100) + 'ms' }"
          />
        </svg>
        <TreeNode
          v-for="(child, index) in node.children"
          :key="child.tempId || child.id || index"
          :node="child"
          :level="level + 1"
          :delay="delay + 200 + index * 100"
          :animation-speed="animationSpeed"
          :auto-expand="autoExpand"
          :show-lines="showLines"
          :highlight-ids="highlightIds"
          :active-id="activeId"
          :animating="animating"
          @node-click="(n) => emit('node-click', n)"
          @expand="(n, exp) => emit('expand', n, exp)"
        />
      </div>
    </div>
    <div class="material-flow-ball" v-if="animating && !hasChildren">
      <div class="ball"></div>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import {
  Folder,
  FolderOpened,
  Document,
  ArrowDown,
  ArrowRight,
  Goods,
  Setting
} from '@element-plus/icons-vue'
const props = defineProps({
  node: {
    type: Object,
    required: true
  },
  level: {
    type: Number,
    default: 0
  },
  delay: {
    type: Number,
    default: 0
  },
  animationSpeed: {
    type: Number,
    default: 1
  },
  autoExpand: {
    type: Boolean,
    default: false
  },
  showLines: {
    type: Boolean,
    default: true
  },
  highlightIds: {
    type: Array,
    default: () => []
  },
  activeId: {
    type: [String, Number],
    default: null
  },
  animating: {
    type: Boolean,
    default: false
  }
})
const emit = defineEmits(['node-click', 'expand'])
const expanded = ref(false)
const nodeWidth = ref(200)
const nodeHeight = ref(60)
const isRoot = computed(() => props.level === 0)
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
const highlighted = computed(() => {
  const nodeId = props.node.tempId || props.node.id
  return props.highlightIds.includes(nodeId)
})
const active = computed(() => {
  const nodeId = props.node.tempId || props.node.id
  return props.activeId === nodeId
})
const toggleExpand = () => {
  expanded.value = !expanded.value
  emit('expand', props.node, expanded.value)
}
const handleClick = () => {
  emit('node-click', props.node)
}
const getLinePath = (index) => {
  const startX = 20
  const startY = 0
  const endX = 20
  const endY = 30 + index * 70
  return `M ${startX} ${startY} L ${startX} ${endY}`
}
watch(() => props.autoExpand, (val) => {
  if (val && hasChildren.value) {
    setTimeout(() => {
      expanded.value = true
    }, props.delay)
  }
})
onMounted(() => {
  if (props.autoExpand && isRoot.value && hasChildren.value) {
    expanded.value = true
  }
})
</script>
<style scoped lang="scss">
.tree-node {
  position: relative;
  margin-left: 20px;
  animation: nodeAppear calc(var(--delay) * var(--speed)) ease-out both;
  &.is-root {
    margin-left: 0;
    .node-content {
      background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
      color: #fff;
      .node-name {
        color: #fff;
      }
      .node-model {
        color: rgba(255, 255, 255, 0.85);
      }
      .node-meta {
        color: rgba(255, 255, 255, 0.9);
        .node-quantity,
        .node-process {
          background: rgba(255, 255, 255, 0.2);
          padding: 2px 8px;
          border-radius: 4px;
        }
      }
    }
  }
  &.is-highlighted {
    .node-content {
      border-color: #e6a23c;
      box-shadow: 0 0 12px rgba(230, 162, 60, 0.4);
    }
  }
  &.is-active {
    .node-content {
      border-color: #409eff;
      box-shadow: 0 0 16px rgba(64, 158, 255, 0.5);
      animation: activePulse 1.5s ease-in-out infinite;
    }
  }
  &.is-animating {
    .node-content {
      animation: nodeFlow calc(2s * var(--speed)) ease-in-out infinite;
    }
  }
  .node-content {
    display: flex;
    align-items: center;
    padding: 12px 16px;
    background: #fff;
    border-radius: 12px;
    border: 2px solid #e4e7ed;
    min-width: 200px;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
    &:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
      border-color: #409eff;
    }
    .node-icon {
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #f5f7fa;
      border-radius: 8px;
      margin-right: 12px;
      .el-icon {
        color: #409eff;
        font-size: 18px;
      }
    }
    .node-info {
      flex: 1;
      .node-name {
        font-weight: 600;
        color: #303133;
        font-size: 14px;
      }
      .node-model {
        color: #909399;
        font-size: 12px;
        margin-top: 2px;
      }
      .node-meta {
        display: flex;
        gap: 8px;
        margin-top: 6px;
        font-size: 12px;
        color: #606266;
        .node-quantity,
        .node-process {
          display: flex;
          align-items: center;
          gap: 4px;
        }
      }
    }
    .node-expand-btn {
      width: 24px;
      height: 24px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #f5f7fa;
      border-radius: 4px;
      transition: background 0.2s;
      &:hover {
        background: #409eff;
        .el-icon {
          color: #fff;
        }
      }
      .el-icon {
        font-size: 14px;
        color: #909399;
      }
    }
  }
  .node-children {
    margin-top: 8px;
    .children-container {
      position: relative;
      padding-left: 20px;
      .connection-lines {
        position: absolute;
        top: 0;
        left: 0;
        width: 40px;
        height: 100%;
        overflow: visible;
        .connection-line {
          stroke: #c0c4cc;
          stroke-width: 2;
          fill: none;
          stroke-dasharray: 100;
          stroke-dashoffset: 100;
          transition: stroke-dashoffset calc(var(--line-delay) * var(--speed)) ease-out;
          &.is-animating {
            stroke-dashoffset: 0;
            stroke: #409eff;
            animation: lineFlow 1s ease-in-out infinite;
          }
        }
      }
    }
  }
  .material-flow-ball {
    position: absolute;
    right: -20px;
    top: 50%;
    transform: translateY(-50%);
    .ball {
      width: 12px;
      height: 12px;
      background: #67c23a;
      border-radius: 50%;
      box-shadow: 0 0 8px rgba(103, 194, 58, 0.6);
      animation: ballPulse 0.8s ease-in-out infinite;
    }
  }
}
@keyframes nodeAppear {
  from {
    opacity: 0;
    transform: translateY(-20px) scale(0.9);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}
@keyframes activePulse {
  0%, 100% {
    box-shadow: 0 0 16px rgba(64, 158, 255, 0.5);
  }
  50% {
    box-shadow: 0 0 24px rgba(64, 158, 255, 0.8);
  }
}
@keyframes nodeFlow {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.02);
  }
}
@keyframes lineFlow {
  0% {
    stroke-dashoffset: 100;
  }
  100% {
    stroke-dashoffset: 0;
  }
}
@keyframes ballPulse {
  0%, 100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.3);
    opacity: 0.8;
  }
}
</style>
src/views/productionManagement/teachingDemo/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,333 @@
<template>
  <div class="app-container teaching-demo-index">
    <PageHeader content="工艺路线与BOM动画教学演示">
      <template #right-button>
        <el-button type="success" @click="goToFactoryDemo">
          <el-icon><Monitor /></el-icon>
          å·¥åŽ‚æ¼”ç¤º
        </el-button>
        <el-button type="primary" @click="goToCombinedDemo">
          è”动演示
        </el-button>
      </template>
    </PageHeader>
    <div class="demo-intro">
      <el-alert
        title="教学演示说明"
        type="info"
        :closable="false"
        show-icon
      >
        <template #default>
          æœ¬æ¨¡å—通过动画演示帮助您理解 <strong>BOM(物料清单)</strong> å’Œ <strong>工艺路线</strong> çš„æ¦‚念。
          é€‰æ‹©ä¸‹æ–¹çš„æ•°æ®åŽç‚¹å‡»è¿›å…¥æ¼”示,即可观看动画讲解。
        </template>
      </el-alert>
    </div>
    <el-row :gutter="20">
      <el-col :span="12">
        <el-card class="demo-card" shadow="hover">
          <template #header>
            <div class="card-header">
              <span class="card-title">
                <el-icon><List /></el-icon>
                BOM ç»“构演示
              </span>
              <el-button type="primary" :disabled="!selectedBom" @click="goToBomDemo">
                è¿›å…¥æ¼”示
              </el-button>
            </div>
          </template>
          <div class="card-content">
            <p class="card-desc">展示物料清单的树形层级结构,理解产品由哪些零部件组成。</p>
            <el-select
              v-model="selectedBom"
              placeholder="请选择一个BOM"
              filterable
              style="width: 100%"
              @change="handleBomChange"
            >
              <el-option
                v-for="item in bomList"
                :key="item.id"
                :label="`${item.bomNo} - ${item.productName}`"
                :value="item.id"
              />
            </el-select>
            <div v-if="selectedBomInfo" class="selected-info">
              <el-descriptions :column="1" border size="small">
                <el-descriptions-item label="BOM编号">{{ selectedBomInfo.bomNo }}</el-descriptions-item>
                <el-descriptions-item label="产品名称">{{ selectedBomInfo.productName }}</el-descriptions-item>
                <el-descriptions-item label="规格型号">{{ selectedBomInfo.productModelName }}</el-descriptions-item>
                <el-descriptions-item label="版本号">{{ selectedBomInfo.version }}</el-descriptions-item>
              </el-descriptions>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card class="demo-card" shadow="hover">
          <template #header>
            <div class="card-header">
              <span class="card-title">
                <el-icon><Connection /></el-icon>
                å·¥è‰ºè·¯çº¿æ¼”示
              </span>
              <el-button type="primary" :disabled="!selectedRoute" @click="goToRouteDemo">
                è¿›å…¥æ¼”示
              </el-button>
            </div>
          </template>
          <div class="card-content">
            <p class="card-desc">展示产品加工的工序流程,理解产品是如何一步步生产出来的。</p>
            <el-select
              v-model="selectedRoute"
              placeholder="请选择一个工艺路线"
              filterable
              style="width: 100%"
              @change="handleRouteChange"
            >
              <el-option
                v-for="item in routeList"
                :key="item.id"
                :label="`${item.processRouteCode} - ${item.productName}`"
                :value="item.id"
              />
            </el-select>
            <div v-if="selectedRouteInfo" class="selected-info">
              <el-descriptions :column="1" border size="small">
                <el-descriptions-item label="路线编号">{{ selectedRouteInfo.processRouteCode }}</el-descriptions-item>
                <el-descriptions-item label="产品名称">{{ selectedRouteInfo.productName }}</el-descriptions-item>
                <el-descriptions-item label="规格名称">{{ selectedRouteInfo.model }}</el-descriptions-item>
                <el-descriptions-item label="BOM编号">{{ selectedRouteInfo.bomNo }}</el-descriptions-item>
              </el-descriptions>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <el-card class="concept-card" shadow="hover">
      <template #header>
        <span class="card-title">
          <el-icon><Reading /></el-icon>
          æ¦‚念说明
        </span>
      </template>
      <el-row :gutter="20">
        <el-col :span="12">
          <div class="concept-item">
            <h4>BOM(物料清单)</h4>
            <p>
              BOM(Bill of Materials)是产品结构的技术文件,它详细记录了产品由哪些零部件、原材料组成,
              ä»¥åŠå„组成部分之间的层级关系和数量关系。
            </p>
            <ul>
              <li><strong>父项:</strong>上层产品或部件</li>
              <li><strong>子项:</strong>组成父项的零部件或原材料</li>
              <li><strong>用量:</strong>生产一个父项需要多少子项</li>
            </ul>
          </div>
        </el-col>
        <el-col :span="12">
          <div class="concept-item">
            <h4>工艺路线</h4>
            <p>
              å·¥è‰ºè·¯çº¿æ˜¯æè¿°äº§å“åŠ å·¥è¿‡ç¨‹çš„æ–‡ä»¶ï¼Œå®ƒè§„å®šäº†äº§å“ä»ŽåŽŸææ–™åˆ°æˆå“éœ€è¦ç»è¿‡å“ªäº›å·¥åºï¼Œ
              ä»¥åŠå„工序的先后顺序和操作要求。
            </p>
            <ul>
              <li><strong>工序:</strong>一个独立的加工步骤</li>
              <li><strong>顺序:</strong>工序执行的先后次序</li>
              <li><strong>参数:</strong>工序的具体操作要求</li>
            </ul>
          </div>
        </el-col>
      </el-row>
    </el-card>
  </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { List, Connection, Reading, Monitor } from '@element-plus/icons-vue'
import { listPage as getBomList } from '@/api/productionManagement/productBom.js'
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
const router = useRouter()
const bomList = ref([])
const routeList = ref([])
const selectedBom = ref(null)
const selectedRoute = ref(null)
const selectedBomInfo = computed(() => {
  return bomList.value.find(item => item.id === selectedBom.value)
})
const selectedRouteInfo = computed(() => {
  return routeList.value.find(item => item.id === selectedRoute.value)
})
const fetchBomList = async () => {
  try {
    const res = await getBomList({ current: 1, size: 100 })
    bomList.value = res?.data?.records || []
  } catch (error) {
    console.error('获取BOM列表失败:', error)
  }
}
const fetchRouteList = async () => {
  try {
    const res = await getRouteList({ current: 1, size: 100 })
    routeList.value = res?.data?.records || []
  } catch (error) {
    console.error('获取工艺路线列表失败:', error)
  }
}
const handleBomChange = (val) => {
  selectedBom.value = val
}
const handleRouteChange = (val) => {
  selectedRoute.value = val
}
const goToBomDemo = () => {
  if (!selectedBom.value) return
  const info = selectedBomInfo.value
  router.push({
    path: '/productionManagement/teachingDemo/bom',
    query: {
      id: selectedBom.value,
      bomNo: info?.bomNo || '',
      productName: info?.productName || '',
      productModelName: info?.productModelName || ''
    }
  })
}
const goToRouteDemo = () => {
  if (!selectedRoute.value) return
  const info = selectedRouteInfo.value
  router.push({
    path: '/productionManagement/teachingDemo/processRoute',
    query: {
      id: selectedRoute.value,
      processRouteCode: info?.processRouteCode || '',
      productName: info?.productName || '',
      model: info?.model || '',
      bomId: info?.bomId || ''
    }
  })
}
const goToCombinedDemo = () => {
  router.push({
    path: '/productionManagement/teachingDemo/combined',
    query: {
      bomId: selectedBom.value || '',
      routeId: selectedRoute.value || ''
    }
  })
}
const goToFactoryDemo = () => {
  router.push({
    path: '/productionManagement/teachingDemo/factory',
    query: {
      routeId: selectedRoute.value || ''
    }
  })
}
onMounted(() => {
  fetchBomList()
  fetchRouteList()
})
</script>
<style scoped lang="scss">
.teaching-demo-index {
  .demo-intro {
    margin-bottom: 20px;
  }
  .demo-card {
    margin-bottom: 20px;
    .card-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      .card-title {
        display: flex;
        align-items: center;
        gap: 8px;
        font-weight: 600;
        font-size: 16px;
      }
    }
    .card-content {
      .card-desc {
        color: #666;
        margin-bottom: 16px;
        line-height: 1.6;
      }
      .selected-info {
        margin-top: 16px;
      }
    }
  }
  .concept-card {
    .card-title {
      display: flex;
      align-items: center;
      gap: 8px;
      font-weight: 600;
      font-size: 16px;
    }
    .concept-item {
      h4 {
        color: #409eff;
        margin-bottom: 12px;
        font-size: 15px;
      }
      p {
        color: #666;
        line-height: 1.8;
        margin-bottom: 12px;
      }
      ul {
        padding-left: 20px;
        color: #888;
        line-height: 1.8;
        li {
          margin-bottom: 4px;
        }
      }
    }
  }
}
</style>