From 47bae1f938f915206e3934ea960aff975e5738c9 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 12 六月 2026 16:09:49 +0800
Subject: [PATCH] feat(teachingDemo): 新增工艺路线与BOM教学演示模块
---
src/views/productionManagement/teachingDemo/index.vue | 333 ++++
.gitignore | 3
src/views/productionManagement/teachingDemo/components/DemoControls.vue | 186 ++
src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue | 535 +++++++
src/views/productionManagement/teachingDemo/BomDemo.vue | 430 ++++++
src/views/productionManagement/teachingDemo/components/TreeNode.vue | 423 ++++++
src/views/productionManagement/teachingDemo/CombinedDemo.vue | 518 +++++++
src/views/productionManagement/teachingDemo/components/ProcessNode.vue | 354 +++++
src/router/index.js | 38
src/views/productionManagement/teachingDemo/FactoryDemo.vue | 1153 +++++++++++++++++
10 files changed, 3,973 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
index f76fc8f..f979342 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,5 +21,8 @@
*.sln
*.local
+# Claude Code
+.claude/
+
package-lock.json
yarn.lock
diff --git a/src/router/index.js b/src/router/index.js
index 7be3668..000a0c5 100644
--- a/src/router/index.js
+++ b/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: "宸ヨ壓璺嚎涓嶣OM鏁欏婕旂ず", 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,
diff --git a/src/views/productionManagement/teachingDemo/BomDemo.vue b/src/views/productionManagement/teachingDemo/BomDemo.vue
new file mode 100644
index 0000000..c6f9897
--- /dev/null
+++ b/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鐨勬牴鑺傜偣浠h〃鏈�缁堟垚鍝併�傝繖閲屾槸鎴戜滑瑕佺敓浜х殑浜у搧锛屾墍鏈夊瓙鑺傜偣閮芥槸涓轰簡鐢熶骇杩欎釜鎴愬搧鎵�闇�瑕佺殑鐗╂枡銆�'
+ },
+ {
+ title: '绗�2姝ワ細灞曞紑鏌ョ湅瀛愰」',
+ content: '鐐瑰嚮鑺傜偣鍙互灞曞紑鏌ョ湅鍏跺瓙椤广�傚瓙椤逛唬琛ㄧ粍鎴愮埗椤圭殑闆堕儴浠舵垨鍘熸潗鏂欍�侭OM鏄竴涓爲褰㈢粨鏋勶紝浣撶幇浜嗕骇鍝佺殑缁勬垚灞傛銆�'
+ },
+ {
+ title: '绗�3姝ワ細鐞嗚В鐗╂枡缁勬垚鍏崇郴',
+ content: '杩炵嚎琛ㄧず鐖跺瓙鍏崇郴銆傛瘡涓瓙鑺傜偣閮芥槸鍏剁埗鑺傜偣鐨勭粍鎴愰儴鍒嗐�備緥濡傦紝涓�涓數鑴戠敱涓绘澘銆丆PU銆佸唴瀛樼瓑缁勬垚銆�'
+ },
+ {
+ title: '绗�4姝ワ細鏌ョ湅鐢ㄩ噺淇℃伅',
+ content: '姣忎釜鑺傜偣涓婄殑鏁板瓧琛ㄧず"鍗曚綅浜у嚭鎵�闇�鏁伴噺"锛屽嵆鐢熶骇涓�涓埗椤归渶瑕佸灏戝瓙椤广�備緥濡傦紝鐢熶骇涓�鍙扮數鑴戦渶瑕�1鍧椾富鏉裤��'
+ },
+ {
+ title: '绗�5姝ワ細浜嗚В宸ュ簭鍏宠仈',
+ content: '瀛愯妭鐐逛笂鐨勫伐搴忓悕绉拌〃绀鸿鐗╂枡鍦ㄥ摢閬撳伐搴忎腑琚秷鑰椼�傝繖杩炴帴浜咮OM涓庡伐鑹鸿矾绾匡紝璇存槑鐗╂枡鍦ㄤ綍鏃惰浣跨敤銆�'
+ }
+]
+
+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>
\ No newline at end of file
diff --git a/src/views/productionManagement/teachingDemo/CombinedDemo.vue b/src/views/productionManagement/teachingDemo/CombinedDemo.vue
new file mode 100644
index 0000000..92c9ba8
--- /dev/null
+++ b/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涓殑姣忎釜鐗╂枡鑺傜偣閮芥湁涓�涓�"娑堣�楀伐搴�"灞炴�э紝琛ㄧず璇ョ墿鏂欏湪鍝亾宸ュ簭涓浣跨敤銆傝繖鏄疊OM涓庡伐鑹鸿矾绾跨殑鏍稿績鍏宠仈銆�'
+ },
+ {
+ title: '绗�4姝ワ細瑙傜湅鐢熶骇杩囩▼妯℃嫙',
+ content: '鐐瑰嚮鎾斁鍙互瑙傜湅浜у搧鍦ㄥ悇宸ュ簭闂存祦杞殑杩囩▼銆傞殢鐫�宸ュ簭鐨勮繘琛岋紝BOM涓殑鐗╂枡琚�愭娑堣�椼��'
+ },
+ {
+ title: '绗�5姝ワ細浣撻獙鑱斿姩浜や簰',
+ content: '鐐瑰嚮宸ュ簭鑺傜偣锛孊OM涓搴旂墿鏂欎細楂樹寒锛涚偣鍑荤墿鏂欒妭鐐癸紝瀵瑰簲宸ュ簭浼氶珮浜�傝繖绉嶈仈鍔ㄥ府鍔╃悊瑙g墿鏂欏湪浣曟椂浣曞湴琚秷鑰椼��'
+ }
+]
+
+const fetchBomList = async () => {
+ try {
+ const res = await getBomList({ current: 1, size: 100 })
+ bomList.value = res?.data?.records || []
+ // 濡傛灉璺敱鏈夐璁綽omId锛岃嚜鍔ㄩ�夋嫨
+ 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 || []
+ // 濡傛灉璺敱鏈夐璁緍outeId锛岃嚜鍔ㄩ�夋嫨
+ 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 = `姝e湪鎵ц "${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>
\ No newline at end of file
diff --git a/src/views/productionManagement/teachingDemo/FactoryDemo.vue b/src/views/productionManagement/teachingDemo/FactoryDemo.vue
new file mode 100644
index 0000000..c7227e2
--- /dev/null
+++ b/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">姝e父</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)"
+ >
+ <!-- 鏈哄櫒澶栧3 -->
+ <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 ? '姝e湪鎶曟枡...' : '浜у搧娴佽浆涓�...' }}
+ </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
+})
+
+// 鎶曞叆鐗╂枡锛堥渶瑕佹姇鍏ョ殑鐗╂枡 - 浠嶣OM涓尮閰嶅伐搴忥級
+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) {
+ // 浼樺厛鐢↖D鍖归厤锛屽叾娆$敤鍚嶇О鍖归厤
+ 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
+
+ // 浠嶣OM涓壘绗竴涓尮閰嶇殑鐗╂枡
+ 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))
+
+ // 鑾峰彇鍏宠仈鐨凚OM鏁版嵁
+ 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>
\ No newline at end of file
diff --git a/src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue b/src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue
new file mode 100644
index 0000000..29f16a6
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/productionManagement/teachingDemo/components/DemoControls.vue b/src/views/productionManagement/teachingDemo/components/DemoControls.vue
new file mode 100644
index 0000000..373c1b8
--- /dev/null
+++ b/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">姝e父</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>
diff --git a/src/views/productionManagement/teachingDemo/components/ProcessNode.vue b/src/views/productionManagement/teachingDemo/components/ProcessNode.vue
new file mode 100644
index 0000000..674a763
--- /dev/null
+++ b/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>
diff --git a/src/views/productionManagement/teachingDemo/components/TreeNode.vue b/src/views/productionManagement/teachingDemo/components/TreeNode.vue
new file mode 100644
index 0000000..54b0afb
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/productionManagement/teachingDemo/index.vue b/src/views/productionManagement/teachingDemo/index.vue
new file mode 100644
index 0000000..ad633ec
--- /dev/null
+++ b/src/views/productionManagement/teachingDemo/index.vue
@@ -0,0 +1,333 @@
+<template>
+ <div class="app-container teaching-demo-index">
+ <PageHeader content="宸ヨ壓璺嚎涓嶣OM鍔ㄧ敾鏁欏婕旂ず">
+ <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> 鐨勬蹇点��
+ 閫夋嫨涓嬫柟鐨勬暟鎹悗鐐瑰嚮杩涘叆婕旂ず锛屽嵆鍙鐪嬪姩鐢昏瑙c��
+ </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="璇烽�夋嫨涓�涓狟OM"
+ 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锛圔ill 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>
--
Gitblit v1.9.3