<template>
|
<div class="app-container factory-demo">
|
<PageHeader content="动态工厂生产线演示">
|
<template #right-button>
|
<el-button @click="goBack">返回</el-button>
|
</template>
|
</PageHeader>
|
|
<div class="control-panel">
|
<el-row :gutter="20" align="middle">
|
<el-col :span="6">
|
<el-select v-model="selectedRouteId" placeholder="选择工艺路线" filterable style="width: 100%" @change="handleRouteChange">
|
<el-option v-for="item in routeList" :key="item.id" :label="`${item.processRouteCode} - ${item.productName}`" :value="item.id" />
|
</el-select>
|
</el-col>
|
<el-col :span="6">
|
<el-button-group>
|
<el-button :type="isPlaying ? 'warning' : 'primary'" @click="togglePlay">
|
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
|
<el-icon v-else><VideoPlay /></el-icon>
|
{{ isPlaying ? '暂停' : '播放' }}
|
</el-button>
|
<el-button @click="handleReset">
|
<el-icon><RefreshRight /></el-icon>
|
重置
|
</el-button>
|
</el-button-group>
|
</el-col>
|
<el-col :span="6">
|
<div class="speed-control">
|
<span>速度:</span>
|
<el-radio-group v-model="speed" size="small">
|
<el-radio-button label="0.5">慢</el-radio-button>
|
<el-radio-button label="1">正常</el-radio-button>
|
<el-radio-button label="2">快</el-radio-button>
|
</el-radio-group>
|
</div>
|
</el-col>
|
<el-col :span="6">
|
<div class="progress-info">
|
<span>进度:</span>
|
<el-progress :percentage="productionProgress" :stroke-width="8" style="width: 120px" />
|
</div>
|
</el-col>
|
</el-row>
|
</div>
|
|
<div class="factory-container" v-loading="loading">
|
<div class="factory-scene">
|
<svg class="factory-svg" viewBox="0 0 1100 500" preserveAspectRatio="xMidYMid meet">
|
<defs>
|
<!-- 渐变定义 -->
|
<linearGradient id="beltGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
<stop offset="0%" stop-color="#409eff" stop-opacity="0.8" />
|
<stop offset="50%" stop-color="#66b1ff" stop-opacity="0.6" />
|
<stop offset="100%" stop-color="#409eff" stop-opacity="0.8" />
|
</linearGradient>
|
<linearGradient id="stationGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
<stop offset="0%" stop-color="#f8f9fa" />
|
<stop offset="100%" stop-color="#e9ecef" />
|
</linearGradient>
|
<linearGradient id="activeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
<stop offset="0%" stop-color="#ecf5ff" />
|
<stop offset="100%" stop-color="#d9ecff" />
|
</linearGradient>
|
<filter id="stationShadow" x="-20%" y="-20%" width="140%" height="140%">
|
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity="0.15" />
|
</filter>
|
<filter id="glowEffect" x="-50%" y="-50%" width="200%" height="200%">
|
<feGaussianBlur stdDeviation="4" result="blur" />
|
<feMerge>
|
<feMergeNode in="blur" />
|
<feMergeNode in="SourceGraphic" />
|
</feMerge>
|
</filter>
|
</defs>
|
|
<!-- 背景地板 -->
|
<rect x="0" y="380" width="1100" height="120" fill="#f5f7fa" />
|
<rect x="0" y="380" width="1100" height="2" fill="#e4e7ed" />
|
|
<!-- 履带 -->
|
<g class="conveyor-system">
|
<!-- 履带主体 -->
|
<rect x="40" y="320" width="1020" height="60" rx="8" fill="url(#beltGradient)" opacity="0.3" />
|
<!-- 履带边缘 -->
|
<rect x="40" y="320" width="1020" height="4" rx="2" fill="#409eff" opacity="0.5" />
|
<rect x="40" y="376" width="1020" height="4" rx="2" fill="#409eff" opacity="0.5" />
|
<!-- 流动线条 -->
|
<path class="belt-flow" d="M50 350 H1050" stroke="#fff" stroke-width="3" stroke-dasharray="15 20" stroke-linecap="round" opacity="0.8" />
|
<!-- 履带滚轮 -->
|
<circle v-for="i in 12" :key="'roller'+i" :cx="50 + i * 85" cy="350" r="8" fill="#fff" opacity="0.6" />
|
</g>
|
|
<!-- 入库区 -->
|
<g class="input-area" @click="showInputDialog">
|
<rect x="20" y="200" width="80" height="80" rx="12" fill="#f0f9eb" stroke="#67c23a" stroke-width="2" filter="url(#stationShadow)" />
|
<text x="60" y="245" text-anchor="middle" font-size="14" fill="#67c23a" font-weight="600">原材料</text>
|
<text x="60" y="265" text-anchor="middle" font-size="12" fill="#67c23a">入库</text>
|
<!-- 入库图标 -->
|
<g transform="translate(45, 215)">
|
<rect width="30" height="20" rx="4" fill="#67c23a" opacity="0.8" />
|
<path d="M15 -8 L15 24" stroke="#67c23a" stroke-width="3" stroke-dasharray="4 4" />
|
</g>
|
</g>
|
|
<!-- 出库区 -->
|
<g class="output-area" @click="showOutputDialog">
|
<rect x="1000" y="200" width="80" height="80" rx="12" fill="#ecf5ff" stroke="#409eff" stroke-width="2" filter="url(#stationShadow)" />
|
<text x="1040" y="245" text-anchor="middle" font-size="14" fill="#409eff" font-weight="600">成品</text>
|
<text x="1040" y="265" text-anchor="middle" font-size="12" fill="#409eff">出库</text>
|
<!-- 出库图标 -->
|
<g transform="translate(1015, 215)">
|
<rect width="30" height="20" rx="4" fill="#409eff" opacity="0.8" />
|
<path d="M15 28 L15 0" stroke="#409eff" stroke-width="3" stroke-dasharray="4 4" />
|
<path d="M8 -5 L15 -12 L22 -5" stroke="#409eff" stroke-width="2" fill="none" />
|
</g>
|
</g>
|
|
<!-- 工序工作站 -->
|
<g
|
v-for="(process, index) in processList"
|
:key="process.id || index"
|
class="work-station"
|
:class="{ 'is-active': currentProcessIndex === index, 'is-completed': completedIndex > index }"
|
:transform="`translate(${getStationX(index)}, 0)`"
|
@click="showProcessDetail(process)"
|
>
|
<!-- 机器外壳 -->
|
<rect x="0" y="80" width="120" height="140" rx="16" :fill="currentProcessIndex === index ? 'url(#activeGradient)' : 'url(#stationGradient)'" :stroke="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#409eff' : '#e4e7ed'" stroke-width="2" filter="url(#stationShadow)" />
|
|
<!-- 工序序号 -->
|
<circle cx="60" cy="60" r="24" :fill="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#409eff' : '#909399'" />
|
<text x="60" y="65" text-anchor="middle" font-size="14" fill="#fff" font-weight="bold">{{ index + 1 }}</text>
|
|
<!-- 工序名称 -->
|
<text x="60" y="110" text-anchor="middle" font-size="13" :fill="currentProcessIndex === index ? '#409eff' : '#303133'" font-weight="600" class="station-name">
|
{{ truncateText(process.technologyOperationName || process.operationName, 8) }}
|
</text>
|
|
<!-- 产品信息 -->
|
<text x="60" y="130" text-anchor="middle" font-size="10" fill="#909399">
|
{{ truncateText(process.productName, 10) || '无产品' }}
|
</text>
|
|
<!-- 状态标签 -->
|
<g transform="translate(20, 145)">
|
<rect width="80" height="20" rx="4" :fill="process.isQuality ? '#fdf6ec' : '#f5f7fa'" />
|
<text x="40" y="14" text-anchor="middle" font-size="10" :fill="process.isQuality ? '#e6a23c' : '#909399'">
|
{{ process.isQuality ? '质检点' : '加工' }}
|
</text>
|
</g>
|
|
<!-- 投料口 -->
|
<g class="feed-point" :transform="`translate(50, 218)`">
|
<circle r="10" fill="#fff" stroke="#409eff" stroke-width="2" :class="{ 'is-feeding': feedingIndex === index }" />
|
<text y="4" text-anchor="middle" font-size="10" fill="#409eff">投</text>
|
</g>
|
|
<!-- 状态指示灯 -->
|
<circle cx="110" cy="90" r="6" :fill="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#e6a23c' : '#c0c4cc'" :class="{ 'is-pulsing': currentProcessIndex === index }" />
|
|
<!-- 投料动画小球 -->
|
<g v-if="feedingIndex === index" class="feed-ball">
|
<circle cx="60" cy="200" r="8" fill="#67c23a">
|
<animate attributeName="cy" values="200;280" dur="0.3s" fill="freeze" />
|
<animate attributeName="opacity" values="1;0" dur="0.3s" fill="freeze" />
|
</circle>
|
</g>
|
</g>
|
|
<!-- 移动的产品箱子 -->
|
<g class="product-boxes">
|
<g v-for="(box, bIndex) in visibleBoxes" :key="bIndex" class="product-box" :transform="`translate(${box.x}, ${box.y})`">
|
<!-- 箱子主体 - 放大尺寸 -->
|
<rect x="0" y="0" width="70" height="45" rx="8" :fill="box.color" opacity="0.95" />
|
<rect x="0" y="15" width="70" height="3" fill="#fff" opacity="0.3" />
|
<!-- 产品名称 -->
|
<text x="35" y="35" text-anchor="middle" font-size="12" fill="#fff" font-weight="600">{{ box.label }}</text>
|
<!-- 悬停提示框 -->
|
<title>{{ box.fullName || box.label }}</title>
|
</g>
|
</g>
|
|
<!-- 投料动画:原料从工序节点落下到产品上 -->
|
<g v-if="showFeedingAnimation && feedingProcessIndex >= 0" class="feeding-animation">
|
<!-- 投入的原料小球 -->
|
<circle
|
:cx="getStationX(feedingProcessIndex) + 60"
|
:cy="feedingAnimY"
|
r="12"
|
fill="#67c23a"
|
opacity="0.9"
|
>
|
<animate
|
attributeName="cy"
|
:values="`${feedingStartY};${feedingEndY}`"
|
:dur="`${0.4 / parseFloat(speed)}s`"
|
fill="freeze"
|
/>
|
<animate
|
attributeName="opacity"
|
values="1;0.6"
|
:dur="`${0.4 / parseFloat(speed)}s`"
|
fill="freeze"
|
/>
|
</circle>
|
<!-- 原料名称 -->
|
<text
|
:x="getStationX(feedingProcessIndex) + 60"
|
:y="feedingAnimY - 18"
|
text-anchor="middle"
|
font-size="10"
|
fill="#67c23a"
|
font-weight="600"
|
opacity="0.9"
|
>
|
{{ truncateText(feedingMaterialName, 5) || '原料' }}
|
</text>
|
</g>
|
|
<!-- 产出动画:加工完成后的闪光效果 -->
|
<g v-if="showOutputAnimation && outputProcessIndex >= 0" class="output-animation">
|
<circle
|
:cx="getStationX(outputProcessIndex) + 60"
|
cy="285"
|
r="30"
|
fill="none"
|
stroke="#409eff"
|
stroke-width="3"
|
opacity="0.8"
|
>
|
<animate
|
attributeName="r"
|
values="20;40"
|
dur="0.5s"
|
fill="freeze"
|
/>
|
<animate
|
attributeName="opacity"
|
values="0.8;0"
|
dur="0.5s"
|
fill="freeze"
|
/>
|
</circle>
|
<!-- 产出名称提示 -->
|
<text
|
:x="getStationX(outputProcessIndex) + 60"
|
y="340"
|
text-anchor="middle"
|
font-size="11"
|
fill="#409eff"
|
font-weight="600"
|
>
|
产出: {{ truncateText(outputProductName, 6) || '产品' }}
|
</text>
|
</g>
|
|
<!-- 进度指示线 -->
|
<path class="progress-line" :d="getProgressPath()" stroke="#67c23a" stroke-width="3" fill="none" opacity="0.6" />
|
|
<!-- 说明文字区域 -->
|
<g class="info-text" v-if="currentProcess">
|
<rect x="200" y="10" width="300" height="50" rx="8" fill="#ecf5ff" stroke="#409eff" stroke-width="1" opacity="0.9" />
|
<text x="350" y="35" text-anchor="middle" font-size="14" fill="#409eff" font-weight="600">
|
当前工序:{{ currentProcess.technologyOperationName || currentProcess.operationName }}
|
</text>
|
<text x="350" y="50" text-anchor="middle" font-size="11" fill="#606266">
|
{{ feedingIndex >= 0 ? '正在投料...' : '产品流转中...' }}
|
</text>
|
</g>
|
</svg>
|
</div>
|
|
<div class="factory-sidebar">
|
<el-card class="info-card" shadow="hover">
|
<template #header>
|
<span>生产线状态</span>
|
</template>
|
<div class="status-info">
|
<div class="status-item">
|
<span class="status-label">工序总数</span>
|
<span class="status-value">{{ processList.length }}</span>
|
</div>
|
<div class="status-item">
|
<span class="status-label">已完成</span>
|
<span class="status-value">{{ completedIndex }} / {{ processList.length }}</span>
|
</div>
|
<div class="status-item">
|
<span class="status-label">当前工序</span>
|
<span class="status-value highlight">{{ currentProcess?.technologyOperationName || currentProcess?.operationName || '-' }}</span>
|
</div>
|
<div class="status-item">
|
<span class="status-label">生产状态</span>
|
<el-tag :type="isPlaying ? 'success' : 'info'" size="small">{{ isPlaying ? '运行中' : '已暂停' }}</el-tag>
|
</div>
|
</div>
|
</el-card>
|
|
<el-card class="legend-card" shadow="hover">
|
<template #header>
|
<span>状态说明</span>
|
</template>
|
<div class="legend-item">
|
<span class="legend-dot completed"></span>
|
<span class="legend-text">已完成</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-dot current"></span>
|
<span class="legend-text">当前工序</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-dot pending"></span>
|
<span class="legend-text">待加工</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-dot quality"></span>
|
<span class="legend-text">质检点</span>
|
</div>
|
</el-card>
|
|
<el-card class="tips-card" shadow="hover">
|
<template #header>
|
<span>操作提示</span>
|
</template>
|
<div class="tips-content">
|
<p>点击工序节点查看详细信息</p>
|
<p>点击"原材料入库"查看投料清单</p>
|
<p>点击"成品出库"查看产出信息</p>
|
<p>使用控制面板调节演示速度</p>
|
</div>
|
</el-card>
|
</div>
|
</div>
|
|
<!-- 工序详情对话框 -->
|
<el-dialog v-model="processDialogVisible" :title="`工序详情 - ${selectedProcess?.technologyOperationName || selectedProcess?.operationName}`" width="650px">
|
<el-descriptions :column="2" border size="small" v-if="selectedProcess">
|
<el-descriptions-item label="工序名称">{{ selectedProcess.technologyOperationName || selectedProcess.operationName }}</el-descriptions-item>
|
<el-descriptions-item label="工序序号">{{ selectedProcess.dragSort || selectedProcessIndex + 1 }}</el-descriptions-item>
|
<el-descriptions-item label="产品名称">{{ selectedProcess.productName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="规格型号">{{ selectedProcess.model || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="单位">{{ selectedProcess.unit || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="计费类型">
|
<el-tag :type="selectedProcess.type === 1 ? 'primary' : 'success'" size="small">
|
{{ selectedProcess.type === 1 ? '计件' : '计时' }}
|
</el-tag>
|
</el-descriptions-item>
|
<el-descriptions-item label="是否质检">
|
<el-tag :type="selectedProcess.isQuality ? 'warning' : 'info'" size="small">{{ selectedProcess.isQuality ? '是' : '否' }}</el-tag>
|
</el-descriptions-item>
|
<el-descriptions-item label="是否生产">
|
<el-tag :type="selectedProcess.isProduction ? 'success' : 'info'" size="small">{{ selectedProcess.isProduction ? '是' : '否' }}</el-tag>
|
</el-descriptions-item>
|
</el-descriptions>
|
|
<!-- 投入物料 -->
|
<div class="material-section" v-if="inputMaterials.length > 0">
|
<div class="section-header">
|
<el-icon style="color: #67c23a;"><Bottom /></el-icon>
|
<h4>需要投入的物料({{ inputMaterials.length }} 种)</h4>
|
</div>
|
<el-table :data="inputMaterials" border size="small" max-height="200">
|
<el-table-column prop="productName" label="物料名称" min-width="120" />
|
<el-table-column prop="model" label="规格型号" min-width="100" />
|
<el-table-column prop="unitQuantity" label="单位用量" width="90" align="right">
|
<template #default="{ row }">
|
<span class="quantity-value">{{ row.unitQuantity }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column prop="unit" label="单位" width="70" align="center" />
|
</el-table>
|
<div class="material-summary">
|
<el-tag type="success" size="small" effect="plain">
|
生产1个单位产品需要投入以上 {{ inputMaterials.length }} 种物料
|
</el-tag>
|
</div>
|
</div>
|
<div class="material-section empty-section" v-else>
|
<el-empty description="该工序暂无需要投入的物料" :image-size="60" />
|
</div>
|
|
<!-- 产出产品 -->
|
<div class="material-section" v-if="outputProduct">
|
<div class="section-header">
|
<el-icon style="color: #409eff;"><Top /></el-icon>
|
<h4>产出产品(本工序产出)</h4>
|
</div>
|
<el-descriptions :column="2" border size="small">
|
<el-descriptions-item label="产品名称">{{ outputProduct.productName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="规格型号">{{ outputProduct.model || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="单位">{{ outputProduct.unit || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="产出类型">{{ selectedProcess?.isProduction ? '生产产出' : '加工产出' }}</el-descriptions-item>
|
</el-descriptions>
|
</div>
|
|
<!-- 上下游工序 -->
|
<div class="material-section" v-if="selectedProcessIndex >= 0">
|
<div class="section-header">
|
<el-icon style="color: #909399;"><Sort /></el-icon>
|
<h4>工序流转</h4>
|
</div>
|
<div class="process-flow">
|
<div class="flow-item" v-if="prevProcess">
|
<span class="flow-label">上一道工序</span>
|
<el-tag type="info" size="small">{{ prevProcess.technologyOperationName || prevProcess.operationName }}</el-tag>
|
</div>
|
<div class="flow-item current">
|
<span class="flow-label">当前工序</span>
|
<el-tag type="primary">{{ selectedProcess?.technologyOperationName || selectedProcess?.operationName }}</el-tag>
|
</div>
|
<div class="flow-item" v-if="nextProcess">
|
<span class="flow-label">下一道工序</span>
|
<el-tag type="info" size="small">{{ nextProcess.technologyOperationName || nextProcess.operationName }}</el-tag>
|
</div>
|
</div>
|
</div>
|
|
<template #footer>
|
<el-button @click="processDialogVisible = false">关闭</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { useRouter } from 'vue-router'
|
import { VideoPlay, VideoPause, RefreshRight, Bottom, Top, Sort } from '@element-plus/icons-vue'
|
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
|
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
|
import { queryList } from '@/api/productionManagement/productStructure.js'
|
|
const router = useRouter()
|
|
const loading = ref(false)
|
const routeList = ref([])
|
const selectedRouteId = ref(null)
|
const selectedBomId = ref(null)
|
const processList = ref([])
|
const bomData = ref([])
|
const isPlaying = ref(false)
|
const speed = ref('1')
|
const currentProcessIndex = ref(-1)
|
const completedIndex = ref(0)
|
const feedingIndex = ref(-1)
|
const processDialogVisible = ref(false)
|
const selectedProcess = ref(null)
|
const selectedProcessIndex = ref(-1)
|
const boxPosition = ref(0)
|
const animationTimer = ref(null)
|
const boxColors = ['#ffb15f', '#28d9cd', '#8b5cf6', '#409eff', '#67c23a']
|
|
// 投料动画相关
|
const showFeedingAnimation = ref(false)
|
const feedingProcessIndex = ref(-1)
|
const feedingMaterialName = ref('')
|
const feedingAnimY = ref(200)
|
const feedingStartY = 200
|
const feedingEndY = 285
|
|
// 产出动画相关
|
const showOutputAnimation = ref(false)
|
const outputProcessIndex = ref(-1)
|
const outputProductName = ref('')
|
|
// 上一次完成的工序索引(用于判断是否需要触发产出动画)
|
const lastCompletedIndex = ref(-1)
|
|
const productionProgress = computed(() => {
|
if (processList.value.length === 0) return 0
|
return Math.round((completedIndex.value / processList.value.length) * 100)
|
})
|
|
const currentProcess = computed(() => {
|
if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
|
return processList.value[currentProcessIndex.value]
|
}
|
return null
|
})
|
|
const processMaterials = computed(() => {
|
if (!selectedProcess.value) return []
|
const processName = selectedProcess.value.technologyOperationName || selectedProcess.value.operationName
|
const materials = []
|
const traverse = (nodes) => {
|
for (const node of nodes) {
|
if (node.processName === processName) {
|
materials.push(node)
|
}
|
if (node.children) traverse(node.children)
|
}
|
}
|
traverse(bomData.value)
|
return materials
|
})
|
|
// 投入物料(需要投入的物料 - 从BOM中匹配工序)
|
const inputMaterials = computed(() => {
|
if (!selectedProcess.value) return []
|
const processId = selectedProcess.value.technologyOperationId || selectedProcess.value.operationId
|
const processName = selectedProcess.value.technologyOperationName || selectedProcess.value.operationName
|
|
const materials = []
|
const traverse = (nodes) => {
|
for (const node of nodes) {
|
// 优先用ID匹配,其次用名称匹配
|
const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
|
const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
|
|
const isMatch = (processId && nodeProcessId === processId) ||
|
(processName && nodeProcessName === processName)
|
|
if (isMatch) {
|
materials.push({
|
productName: node.productName || '未命名物料',
|
model: node.model || '-',
|
unitQuantity: node.unitQuantity || 1,
|
unit: node.unit || '-',
|
processName: nodeProcessName || '-'
|
})
|
}
|
if (node.children && node.children.length > 0) {
|
traverse(node.children)
|
}
|
}
|
}
|
traverse(bomData.value)
|
return materials
|
})
|
|
// 产出产品(当前工序关联的产品信息)
|
const outputProduct = computed(() => {
|
if (!selectedProcess.value) return null
|
return {
|
productName: selectedProcess.value.productName || null,
|
model: selectedProcess.value.model || null,
|
unit: selectedProcess.value.unit || null
|
}
|
})
|
|
// 上一道工序
|
const prevProcess = computed(() => {
|
if (selectedProcessIndex.value <= 0) return null
|
return processList.value[selectedProcessIndex.value - 1]
|
})
|
|
// 下一道工序
|
const nextProcess = computed(() => {
|
if (selectedProcessIndex.value < 0 || selectedProcessIndex.value >= processList.value.length - 1) return null
|
return processList.value[selectedProcessIndex.value + 1]
|
})
|
|
const visibleBoxes = computed(() => {
|
const boxes = []
|
const baseX = 60 + boxPosition.value
|
|
// 计算产品当前所在的工序索引
|
const totalWidth = 900
|
const progressRatio = Math.min(1, boxPosition.value / totalWidth)
|
const currentStep = Math.floor(progressRatio * processList.value.length)
|
|
// 当前工序
|
const currentProc = processList.value[currentStep] || null
|
// 上一道工序(已完成)
|
const prevProc = processList.value[currentStep - 1] || null
|
|
// 确定产品名称
|
let productLabel = '原料'
|
let fullName = '原材料(待加工)'
|
if (currentStep > 0 && prevProc) {
|
// 已经过了一些工序,显示上一个工序的产出
|
productLabel = truncateText(prevProc.productName, 5) || '半成品'
|
fullName = prevProc.productName || '半成品'
|
}
|
if (currentStep >= processList.value.length) {
|
// 全部完成
|
const lastProc = processList.value[processList.value.length - 1]
|
productLabel = truncateText(lastProc?.productName, 5) || '成品'
|
fullName = lastProc?.productName || '成品'
|
}
|
|
if (baseX > 40) {
|
boxes.push({
|
x: baseX,
|
y: 275, // 调整位置,让箱子在履带上
|
color: '#409eff',
|
label: productLabel,
|
fullName: fullName,
|
processIndex: currentStep,
|
isFeeding: feedingIndex.value === currentStep && currentStep < processList.value.length
|
})
|
}
|
|
return boxes
|
})
|
|
// 当前工序的投入物料名称
|
const currentInputMaterial = computed(() => {
|
if (currentProcessIndex.value < 0 || currentProcessIndex.value >= processList.value.length) return null
|
const process = processList.value[currentProcessIndex.value]
|
if (!process) return null
|
|
const processId = process.technologyOperationId || process.operationId
|
const processName = process.technologyOperationName || process.operationName
|
|
// 从BOM中找第一个匹配的物料
|
let material = null
|
const traverse = (nodes) => {
|
for (const node of nodes) {
|
const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
|
const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
|
if ((processId && nodeProcessId === processId) || (processName && nodeProcessName === processName)) {
|
material = node
|
return true
|
}
|
if (node.children && node.children.length > 0) {
|
if (traverse(node.children)) return true
|
}
|
}
|
return false
|
}
|
traverse(bomData.value)
|
return material
|
})
|
|
const getStationX = (index) => {
|
const totalStations = processList.value.length
|
if (totalStations === 0) return 0
|
const startX = 140
|
const spacing = Math.min(180, (900 - startX) / totalStations)
|
return startX + index * spacing
|
}
|
|
const truncateText = (text, maxLen) => {
|
if (!text) return ''
|
return text.length > maxLen ? text.substring(0, maxLen) + '..' : text
|
}
|
|
const getProgressPath = () => {
|
if (completedIndex.value === 0) return ''
|
const endX = getStationX(Math.min(completedIndex.value, processList.value.length) - 1) + 60
|
return `M60 350 L${endX} 350`
|
}
|
|
const fetchRouteList = async () => {
|
try {
|
const res = await getRouteList({ current: 1, size: 100 })
|
routeList.value = res?.data?.records || []
|
} catch (error) {
|
console.error('获取工艺路线列表失败:', error)
|
}
|
}
|
|
const fetchProcessList = async () => {
|
if (!selectedRouteId.value) return
|
loading.value = true
|
try {
|
const res = await findProcessRouteItemList({ routeId: selectedRouteId.value })
|
processList.value = res?.data || []
|
processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
|
|
// 获取关联的BOM数据
|
const routeInfo = routeList.value.find(r => r.id === selectedRouteId.value)
|
if (routeInfo?.bomId) {
|
selectedBomId.value = routeInfo.bomId
|
const bomRes = await queryList(routeInfo.bomId)
|
bomData.value = bomRes?.data || []
|
}
|
} catch (error) {
|
console.error('获取工艺路线数据失败:', error)
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const handleRouteChange = () => {
|
handleReset()
|
fetchProcessList()
|
}
|
|
const togglePlay = () => {
|
isPlaying.value = !isPlaying.value
|
if (isPlaying.value) {
|
startAnimation()
|
} else {
|
stopAnimation()
|
}
|
}
|
|
const handleReset = () => {
|
stopAnimation()
|
isPlaying.value = false
|
currentProcessIndex.value = -1
|
completedIndex.value = 0
|
feedingIndex.value = -1
|
boxPosition.value = 0
|
showFeedingAnimation.value = false
|
showOutputAnimation.value = false
|
feedingProcessIndex.value = -1
|
outputProcessIndex.value = -1
|
lastCompletedIndex.value = -1
|
}
|
|
const startAnimation = () => {
|
const speedFactor = parseFloat(speed.value)
|
const interval = 2000 / speedFactor
|
const moveStep = 20 / speedFactor
|
|
animationTimer.value = setInterval(() => {
|
if (!isPlaying.value) return
|
|
// 更新箱子位置
|
boxPosition.value += moveStep
|
|
// 计算当前应该在哪个工序
|
const totalWidth = 900
|
const progressRatio = boxPosition.value / totalWidth
|
const newIndex = Math.floor(progressRatio * processList.value.length)
|
|
// 进入新工序时触发投料动画
|
if (newIndex !== currentProcessIndex.value && newIndex < processList.value.length) {
|
// 先触发产出动画(上一道工序完成)
|
if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
|
const prevProcess = processList.value[currentProcessIndex.value]
|
triggerOutputAnimation(currentProcessIndex.value, prevProcess?.productName || '产品')
|
}
|
|
currentProcessIndex.value = newIndex
|
|
// 触发投料动画
|
const process = processList.value[newIndex]
|
const inputMaterial = getInputMaterialForProcess(process)
|
triggerFeedingAnimation(newIndex, inputMaterial?.productName || '原料')
|
|
feedingIndex.value = newIndex
|
setTimeout(() => {
|
feedingIndex.value = -1
|
}, 500 / speedFactor)
|
}
|
|
// 判断是否完成
|
if (boxPosition.value >= totalWidth) {
|
// 最后一个工序完成,触发产出动画
|
if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
|
const lastProcess = processList.value[currentProcessIndex.value]
|
triggerOutputAnimation(currentProcessIndex.value, lastProcess?.productName || '成品')
|
}
|
|
completedIndex.value = processList.value.length
|
currentProcessIndex.value = -1
|
// 循环播放
|
setTimeout(() => {
|
boxPosition.value = 0
|
completedIndex.value = 0
|
currentProcessIndex.value = -1
|
lastCompletedIndex.value = -1
|
showFeedingAnimation.value = false
|
showOutputAnimation.value = false
|
}, 1500 / speedFactor)
|
} else if (newIndex >= 0) {
|
completedIndex.value = newIndex
|
}
|
}, interval)
|
}
|
|
// 获取工序需要投入的物料
|
const getInputMaterialForProcess = (process) => {
|
if (!process) return null
|
const processId = process.technologyOperationId || process.operationId
|
const processName = process.technologyOperationName || process.operationName
|
|
let material = null
|
const traverse = (nodes) => {
|
for (const node of nodes) {
|
const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
|
const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
|
if ((processId && nodeProcessId === processId) || (processName && nodeProcessName === processName)) {
|
material = node
|
return true
|
}
|
if (node.children && node.children.length > 0) {
|
if (traverse(node.children)) return true
|
}
|
}
|
return false
|
}
|
traverse(bomData.value)
|
return material
|
}
|
|
// 触发投料动画
|
const triggerFeedingAnimation = (processIndex, materialName) => {
|
showFeedingAnimation.value = true
|
feedingProcessIndex.value = processIndex
|
feedingMaterialName.value = materialName
|
feedingAnimY.value = feedingStartY
|
|
setTimeout(() => {
|
showFeedingAnimation.value = false
|
}, 600 / parseFloat(speed.value))
|
}
|
|
// 触发产出动画
|
const triggerOutputAnimation = (processIndex, productName) => {
|
showOutputAnimation.value = true
|
outputProcessIndex.value = processIndex
|
outputProductName.value = productName
|
|
setTimeout(() => {
|
showOutputAnimation.value = false
|
}, 800 / parseFloat(speed.value))
|
}
|
|
const stopAnimation = () => {
|
if (animationTimer.value) {
|
clearInterval(animationTimer.value)
|
animationTimer.value = null
|
}
|
}
|
|
const showProcessDetail = (process) => {
|
selectedProcess.value = process
|
// 查找工序索引
|
selectedProcessIndex.value = processList.value.findIndex(p =>
|
p.id === process.id ||
|
(p.technologyOperationName === process.technologyOperationName && p.productName === process.productName)
|
)
|
processDialogVisible.value = true
|
}
|
|
const showInputDialog = () => {
|
// 可以展示原材料入库信息
|
}
|
|
const showOutputDialog = () => {
|
// 可以展示成品出库信息
|
}
|
|
const goBack = () => {
|
router.push('/productionManagement/teachingDemo')
|
}
|
|
watch(speed, () => {
|
if (isPlaying.value) {
|
stopAnimation()
|
startAnimation()
|
}
|
})
|
|
onMounted(() => {
|
fetchRouteList()
|
})
|
|
onUnmounted(() => {
|
stopAnimation()
|
})
|
</script>
|
|
<style scoped lang="scss">
|
.factory-demo {
|
.control-panel {
|
background: #fff;
|
padding: 20px;
|
border-radius: 12px;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
margin-bottom: 20px;
|
|
.speed-control {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.progress-info {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
}
|
|
.factory-container {
|
display: flex;
|
gap: 20px;
|
min-height: 500px;
|
|
.factory-scene {
|
flex: 1;
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
border-radius: 16px;
|
padding: 20px;
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
overflow: hidden;
|
|
.factory-svg {
|
width: 100%;
|
height: auto;
|
min-height: 450px;
|
|
.conveyor-system {
|
.belt-flow {
|
animation: beltFlow 0.8s linear infinite;
|
}
|
}
|
|
.work-station {
|
cursor: pointer;
|
|
&:hover {
|
filter: brightness(1.1) drop-shadow(0 0 12px rgba(64, 158, 255, 0.4));
|
}
|
|
&.is-active {
|
.station-name {
|
animation: textGlow 1s ease-in-out infinite;
|
}
|
}
|
|
.feed-point {
|
transition: all 0.3s ease;
|
|
&.is-feeding {
|
animation: feedPulse 0.3s ease-out;
|
}
|
}
|
|
.is-pulsing {
|
animation: statusPulse 1s ease-in-out infinite;
|
}
|
}
|
|
.product-box {
|
transition: transform 0.1s linear;
|
}
|
|
.product-boxes {
|
animation: boxesMove calc(8s / var(--speed, 1)) linear infinite;
|
}
|
|
.info-text {
|
animation: fadeInOut 0.5s ease;
|
}
|
}
|
}
|
|
.factory-sidebar {
|
width: 280px;
|
display: flex;
|
flex-direction: column;
|
gap: 16px;
|
|
.info-card {
|
.status-info {
|
.status-item {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
padding: 8px 0;
|
border-bottom: 1px solid #f0f0f0;
|
|
&:last-child {
|
border-bottom: none;
|
}
|
|
.status-label {
|
color: #909399;
|
font-size: 13px;
|
}
|
|
.status-value {
|
color: #303133;
|
font-weight: 600;
|
|
&.highlight {
|
color: #409eff;
|
}
|
}
|
}
|
}
|
}
|
|
.legend-card {
|
.legend-item {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
margin-bottom: 8px;
|
|
.legend-dot {
|
width: 12px;
|
height: 12px;
|
border-radius: 50%;
|
|
&.completed {
|
background: #67c23a;
|
}
|
|
&.current {
|
background: #e6a23c;
|
animation: legendPulse 1s ease-in-out infinite;
|
}
|
|
&.pending {
|
background: #c0c4cc;
|
}
|
|
&.quality {
|
background: #fdf6ec;
|
border: 2px solid #e6a23c;
|
}
|
}
|
|
.legend-text {
|
font-size: 12px;
|
color: #666;
|
}
|
}
|
}
|
|
.tips-card {
|
.tips-content {
|
p {
|
font-size: 12px;
|
color: #909399;
|
margin-bottom: 8px;
|
line-height: 1.6;
|
|
&:before {
|
content: '• ';
|
color: #409eff;
|
}
|
}
|
}
|
}
|
}
|
}
|
|
.material-section {
|
margin-top: 16px;
|
|
.section-header {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
margin-bottom: 12px;
|
|
h4 {
|
color: #303133;
|
font-size: 14px;
|
margin: 0;
|
font-weight: 600;
|
}
|
}
|
|
&.empty-section {
|
margin-top: 12px;
|
}
|
|
.quantity-value {
|
font-weight: 600;
|
color: #67c23a;
|
}
|
|
.material-summary {
|
margin-top: 8px;
|
}
|
|
.process-flow {
|
display: flex;
|
align-items: center;
|
gap: 16px;
|
flex-wrap: wrap;
|
|
.flow-item {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
|
.flow-label {
|
font-size: 12px;
|
color: #909399;
|
}
|
|
&.current {
|
.flow-label {
|
color: #409eff;
|
font-weight: 600;
|
}
|
}
|
}
|
}
|
}
|
}
|
|
@keyframes beltFlow {
|
to {
|
stroke-dashoffset: -35;
|
}
|
}
|
|
@keyframes statusPulse {
|
0%, 100% {
|
opacity: 0.6;
|
transform: scale(1);
|
}
|
50% {
|
opacity: 1;
|
transform: scale(1.2);
|
}
|
}
|
|
@keyframes feedPulse {
|
0% {
|
transform: scale(1);
|
}
|
50% {
|
transform: scale(1.3);
|
fill: #67c23a;
|
}
|
100% {
|
transform: scale(1);
|
}
|
}
|
|
@keyframes textGlow {
|
0%, 100% {
|
opacity: 1;
|
}
|
50% {
|
opacity: 0.8;
|
}
|
}
|
|
@keyframes legendPulse {
|
0%, 100% {
|
box-shadow: 0 0 4px rgba(230, 162, 60, 0.4);
|
}
|
50% {
|
box-shadow: 0 0 8px rgba(230, 162, 60, 0.6);
|
}
|
}
|
|
@keyframes fadeInOut {
|
from {
|
opacity: 0;
|
transform: translateY(-10px);
|
}
|
to {
|
opacity: 1;
|
transform: translateY(0);
|
}
|
}
|
</style>
|