Files
l2-g30/src/views/l2/track/rect.vue

2365 lines
73 KiB
Vue
Raw Normal View History

2025-12-26 09:18:12 +08:00
<template>
<div class="track-container">
<el-row :gutter="20">
<!-- 左侧设备列表 -->
<el-col :span="16">
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-container">
<el-icon class="is-loading"><i class="el-icon-loading"></i></el-icon>
<span>加载中...</span>
</div>
<!-- WebSocket 连接状态指示 -->
<div v-if="!isLoading" class="ws-status-bar">
<el-tooltip content="测量数据" placement="top">
<el-badge :is-dot="true" :type="socketStatus.measure ? 'success' : 'danger'">
<i class="el-icon-data-analysis"></i>
</el-badge>
</el-tooltip>
<el-tooltip content="位置追踪" placement="top">
<el-badge :is-dot="true" :type="socketStatus.position ? 'success' : 'danger'">
<i class="el-icon-location"></i>
</el-badge>
</el-tooltip>
<el-tooltip content="操作信号" placement="top">
<el-badge :is-dot="true" :type="socketStatus.signal ? 'success' : 'danger'">
<i class="el-icon-bell"></i>
</el-badge>
</el-tooltip>
<el-tooltip content="物料映射" placement="top">
<el-badge :is-dot="true" :type="socketStatus.matmap ? 'success' : 'danger'">
<i class="el-icon-map-location"></i>
</el-badge>
</el-tooltip>
<el-tooltip content="计算结果" placement="top">
<el-badge :is-dot="true" :type="socketStatus.calcSetup ? 'success' : 'danger'">
<i class="el-icon-s-marketing"></i>
</el-badge>
</el-tooltip>
</div>
<div v-if="!isLoading" class="device-layout">
<!-- 入口段区域 -->
<div class="section-area entry-area">
<div class="section-header">
入口段
<span class="section-info" v-if="positionData.entrySpeed">速度: {{ positionData.entrySpeed.toFixed(1) }} m/min</span>
</div>
<div class="section-summary" v-if="entrySectionMetrics.length">
<div class="summary-item" v-for="item in entrySectionMetrics" :key="item.label">
<span class="summary-label">{{ item.label }}</span>
<span class="summary-value">{{ item.value }}</span>
</div>
</div>
<div class="device-grid">
<div v-for="device in entryDevicesList" :key="device.positionNameEn" class="device-card" :class="{
active: selectedCard && selectedCard.positionNameEn === device.positionNameEn,
'has-data': hasMatId(device.positionNameEn)
}" @click="selectDevice(device.positionNameEn)">
<div class="device-name">{{ device.positionNameCn }}</div>
<div class="device-code">{{ device.positionNameEn }}</div>
<div class="device-status">
<span class="coil-id" :class="{ 'status-working': isDeviceWorking(device.positionNameEn), 'status-idle': !isDeviceWorking(device.positionNameEn) }">
{{ getDeviceStatus(device.positionNameEn) }}
</span>
</div>
</div>
</div>
</div>
<!-- 熔炉段区域 -->
<div class="section-area furnace-area">
<div class="section-header">
熔炉段
<span class="section-info" v-if="positionData.technologySpeed">速度: {{ positionData.technologySpeed.toFixed(1) }} m/min</span>
</div>
<div class="section-summary" v-if="furnaceSectionMetrics.length">
<div class="summary-item" v-for="item in furnaceSectionMetrics" :key="item.label">
<span class="summary-label">{{ item.label }}</span>
<span class="summary-value">{{ item.value }}</span>
</div>
</div>
<div class="device-grid">
<div v-for="device in furnaceDevicesList" :key="device.positionNameEn" class="device-card" :class="{
active: selectedCard && selectedCard.positionNameEn === device.positionNameEn,
'has-data': hasMatId(device.positionNameEn)
}" @click="selectDevice(device.positionNameEn)">
<div class="device-name">{{ device.positionNameCn }}</div>
<div class="device-code">{{ device.positionNameEn }}</div>
<div class="device-status">
<span class="coil-id" :class="{ 'status-working': isDeviceWorking(device.positionNameEn), 'status-idle': !isDeviceWorking(device.positionNameEn) }">
{{ getDeviceStatus(device.positionNameEn) }}
</span>
</div>
</div>
</div>
</div>
<!-- 涂层段区域 -->
<div class="section-area coat-area">
<div class="section-header">涂层段</div>
<div class="section-summary" v-if="coatSectionMetrics.length">
<div class="summary-item" v-for="item in coatSectionMetrics" :key="item.label">
<span class="summary-label">{{ item.label }}</span>
<span class="summary-value">{{ item.value }}</span>
</div>
</div>
<div class="device-grid">
<div v-for="device in coatDevicesList" :key="device.positionNameEn" class="device-card" :class="{
active: selectedCard && selectedCard.positionNameEn === device.positionNameEn,
'has-data': hasMatId(device.positionNameEn)
}" @click="selectDevice(device.positionNameEn)">
<div class="device-name">{{ device.positionNameCn }}</div>
<div class="device-code">{{ device.positionNameEn }}</div>
<div class="device-status">
<span class="coil-id" :class="{ 'status-working': isDeviceWorking(device.positionNameEn), 'status-idle': !isDeviceWorking(device.positionNameEn) }">
{{ getDeviceStatus(device.positionNameEn) }}
</span>
</div>
</div>
</div>
</div>
<!-- 出口段区域 -->
<div class="section-area exit-area">
<div class="section-header">
出口段
<span class="section-info" v-if="positionData.exitSpeed">速度: {{ positionData.exitSpeed.toFixed(1) }} m/min</span>
</div>
<div class="section-summary" v-if="exitSectionMetrics.length">
<div class="summary-item" v-for="item in exitSectionMetrics" :key="item.label">
<span class="summary-label">{{ item.label }}</span>
<span class="summary-value">{{ item.value }}</span>
</div>
</div>
<div class="device-grid">
<div v-for="device in exitDevicesList" :key="device.positionNameEn" class="device-card" :class="{
active: selectedCard && selectedCard.positionNameEn === device.positionNameEn,
'has-data': hasMatId(device.positionNameEn)
}" @click="selectDevice(device.positionNameEn)">
<div class="device-name">{{ device.positionNameCn }}</div>
<div class="device-code">{{ device.positionNameEn }}</div>
<div class="device-status">
<span class="coil-id" :class="{ 'status-working': isDeviceWorking(device.positionNameEn), 'status-idle': !isDeviceWorking(device.positionNameEn) }">
{{ getDeviceStatus(device.positionNameEn) }}
</span>
</div>
</div>
</div>
</div>
<!-- 其他段区域 -->
<div class="section-area" v-if="exitOtherDevicesList.length">
<div class="section-header">其他段</div>
<div class="device-grid other-exit-grid">
<div v-for="device in exitOtherDevicesList" :key="device.positionNameEn" class="device-card" :class="{
active: selectedCard && selectedCard.positionNameEn === device.positionNameEn,
'has-data': hasMatId(device.positionNameEn)
}" @click="selectDevice(device.positionNameEn)">
<div class="device-name">{{ device.positionNameCn }}</div>
<div class="device-code">{{ device.positionNameEn }}</div>
<div class="device-status">
<span class="coil-id" :class="{ 'status-working': isDeviceWorking(device.positionNameEn), 'status-idle': !isDeviceWorking(device.positionNameEn) }">
{{ getDeviceStatus(device.positionNameEn) }}
</span>
</div>
</div>
</div>
</div>
</div>
</el-col>
<!-- 右侧信息面板 -->
<el-col :span="8">
<div class="info-panels">
<!-- 生产计划列表 -->
<div class="panel">
<div class="panel-title">
<i class="el-icon-s-order"></i> 生产计划
</div>
<div class="plan-list-vertical">
<el-empty v-if="planQueue.length === 0" description="暂无生产计划" :image-size="80" />
<div
v-else
v-for="(plan, index) in planQueue"
:key="plan.id || plan.planid || index"
class="plan-item-vertical"
:class="getPlanItemClass(plan)"
@click="selectPlan(plan)"
>
<div class="plan-item-top">
<div class="plan-order-dot" :class="getPlanOrderClass(plan.status)">{{ index + 1 }}</div>
<div class="plan-title-text">
<div class="plan-id">计划ID{{ plan.planid || '-' }}</div>
<div class="plan-coil">钢卷号{{ plan.coilid || '-' }}</div>
</div>
<el-tag :type="getPlanStatusTagType(plan.status)" size="mini">{{ getPlanStatusText(plan.status) }}</el-tag>
</div>
<div class="plan-item-bottom">
<span>钢种{{ plan.steelGrade || '-' }}</span>
<span>顺序{{ plan.seqid || '-' }}</span>
</div>
</div>
</div>
</div>
<!-- 生产计划详情 -->
<div class="panel" v-if="selectedPlan">
<div class="panel-title">
<i class="el-icon-document"></i> 生产计划详情
<el-button size="mini" type="text" icon="el-icon-close" @click="selectedPlan = null"></el-button>
</div>
<div class="plan-detail-content">
<!-- 位置信息如果在产线上 -->
<div v-if="selectedPlanPosition" class="position-alert">
<div class="position-icon">
<i class="el-icon-location"></i>
</div>
<div class="position-info">
<div class="position-label">当前位置</div>
<div class="position-name">{{ selectedPlanPosition.positionNameCn }}</div>
<div class="position-code">{{ selectedPlanPosition.positionNameEn }}</div>
</div>
</div>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="计划ID">{{ selectedPlan.planid || '-' }}</el-descriptions-item>
<el-descriptions-item label="钢卷号">{{ selectedPlan.coilid || '-' }}</el-descriptions-item>
<el-descriptions-item label="顺序号">{{ selectedPlan.seqid || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getPlanStatusTagType(selectedPlan.status)" size="small" effect="dark">
{{ getPlanStatusText(selectedPlan.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="钢种">{{ selectedPlan.steelGrade || '-' }}</el-descriptions-item>
<el-descriptions-item label="入口厚度">
{{ selectedPlan.entryThick ? selectedPlan.entryThick + ' mm' : '-' }}
</el-descriptions-item>
<el-descriptions-item label="入口宽度">
{{ selectedPlan.entryWidth ? selectedPlan.entryWidth + ' mm' : '-' }}
</el-descriptions-item>
<el-descriptions-item label="入口重量">
{{ selectedPlan.entryWeight ? selectedPlan.entryWeight + ' t' : '-' }}
</el-descriptions-item>
<el-descriptions-item label="入口长度">
{{ selectedPlan.entryLength ? selectedPlan.entryLength + ' mm' : '-' }}
</el-descriptions-item>
<el-descriptions-item label="订单号">{{ selectedPlan.orderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="机组号">{{ selectedPlan.unitCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="计划类型">{{ selectedPlan.planType || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 时间信息 -->
<div class="plan-time-info" v-if="selectedPlan.onlineDate || selectedPlan.startDate || selectedPlan.endDate">
<div class="info-subtitle">时间信息</div>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="上线时间" v-if="selectedPlan.onlineDate">
{{ formatDateTime(selectedPlan.onlineDate) }}
</el-descriptions-item>
<el-descriptions-item label="开始时间" v-if="selectedPlan.startDate">
{{ formatDateTime(selectedPlan.startDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束时间" v-if="selectedPlan.endDate">
{{ formatDateTime(selectedPlan.endDate) }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</div>
<!-- 最近操作信号 -->
<div class="panel" v-if="signalData">
<div class="panel-title">
<i class="el-icon-bell"></i> 最近操作
<el-tag
:type="getOperationTagType(signalData.operation)"
size="mini"
effect="dark">
{{ getOperationConfig(signalData.operation).icon }} {{ getOperationText(signalData.operation) }}
</el-tag>
</div>
<div class="signal-info" :class="getSignalPanelClass(signalData.operation)">
<el-tag :type="signalData.autoFlag === 1 ? 'warning' : 'info'" size="small">
{{ signalData.autoFlag === 1 ? '手动操作' : '自动操作' }}
</el-tag>
<div class="signal-detail">
<div><strong>操作类型:</strong>
<span :class="getOperationTextClass(signalData.operation)">
{{ getOperationText(signalData.operation) }}
</span>
</div>
<div><strong>钢卷号:</strong> {{ producingCoilId || signalData.entryMatId || '-' }}</div>
<div><strong>计划ID:</strong> {{ signalData.planId || '-' }}</div>
<div v-if="signalData.porIdx !== null && signalData.porIdx !== undefined">
<strong>开卷机:</strong> {{ signalData.porIdx }}
</div>
<div v-if="signalData.trIdx !== null && signalData.trIdx !== undefined">
<strong>卷取机:</strong> {{ signalData.trIdx }}
</div>
<div v-if="signalData.virtualPlanFlag">
<el-tag type="danger" size="mini">虚拟卷</el-tag>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="panel">
<div class="panel-title">操作</div>
<div class="btn-list">
<el-button class="action-btn" size="small" @click="handleOperate(selectedCard, 'ONLINE')">钢卷上线</el-button>
<el-button class="action-btn" size="small" @click="handleOperate(selectedCard, 'UNLOAD')">手动卸卷</el-button>
<el-button class="action-btn" size="small"
@click="handleOperate(selectedCard, 'ALL_RETURN')">整卷回退</el-button>
<el-button class="action-btn" size="small"
@click="handleOperate(selectedCard, 'HALF_RETURN')">半卷回退</el-button>
<el-button class="action-btn" size="small" @click="handleOperate(selectedCard, 'BLOCK')">卸卷并封闭</el-button>
</div>
</div>
<!-- 设备基本信息 -->
<div class="panel" v-if="selectedCard">
<div class="panel-title">基本信息</div>
<table class="info-table">
<tr>
<td>位置名称</td>
<td>{{ selectedCard.positionNameCn || '-' }}</td>
</tr>
<tr>
<td>位置代号</td>
<td>{{ selectedCard.positionNameEn || '-' }}</td>
</tr>
<tr>
<td>钢卷号</td>
<td>{{ selectedCard.matId || '-' }}</td>
</tr>
<tr>
<td>计划ID</td>
<td>{{ selectedCard.planId || '-' }}</td>
</tr>
<tr>
<td>计划号</td>
<td>{{ selectedCard.planNo || '-' }}</td>
</tr>
</table>
</div>
<!-- 设备实时数据 -->
<div class="panel">
<!-- 调整工具选择两个位置两个下拉选分别双向绑定 -->
<el-form :model="adjustForm" ref="adjustForm" label-width="80px">
<el-form-item label="当前位置" prop="current">
<el-select v-model="adjustForm.current" placeholder="请选择当前位置">
<el-option v-for="item in matMapList" :key="item.positionNameEn" :label="item.positionNameCn"
:value="item.positionNameEn"></el-option>
</el-select>
</el-form-item>
<el-form-item label="目标位置" prop="target">
<el-select v-model="adjustForm.target" placeholder="请选择目标位置">
<el-option v-for="item in matMapList" :key="item.positionNameEn" :label="item.positionNameCn"
:value="item.positionNameEn"></el-option>
</el-select>
</el-form-item>
</el-form>
<el-button type="primary" :disabled="!adjustForm.current || !adjustForm.target"
@click="handleConfirmAdjust">确认调整</el-button>
</div>
</div>
</el-col>
</el-row>
<!-- 计算结果对话框 -->
<el-dialog
title="计算设定结果"
:visible.sync="showCalcResultDialog"
width="80%"
v-if="calcSetupResult"
>
<div class="calc-result-header">
<el-tag :type="calcSetupResult.flag ? 'success' : 'danger'">
{{ calcSetupResult.flag ? '计算成功' : '计算失败' }}
</el-tag>
<span>Key: {{ calcSetupResult.key }}</span>
</div>
<el-table
v-if="calcSetupResult.flag && calcSetupResult.lists && calcSetupResult.lists.length > 0"
:data="calcSetupResult.lists"
border
stripe
max-height="500"
>
<el-table-column prop="passno" label="道次号" width="80" fixed></el-table-column>
<el-table-column prop="entryThick" label="入口厚度(mm)" width="120"></el-table-column>
<el-table-column prop="exitThick" label="出口厚度(mm)" width="120"></el-table-column>
<el-table-column prop="reduction" label="压下率(%)" width="100"></el-table-column>
<el-table-column prop="rollSpeed" label="轧制速度" width="120"></el-table-column>
<el-table-column prop="rollForce" label="轧制力(kN)" width="120"></el-table-column>
<el-table-column prop="entryTension" label="入口张力" width="120"></el-table-column>
<el-table-column prop="exitTension" label="出口张力" width="120"></el-table-column>
</el-table>
<div v-else class="empty-msg">无计算结果数据</div>
</el-dialog>
<el-dialog :visible.sync="operateMatStatus" :title="getOperateTitle" width="50%">
<el-form :model="operateMatForm" :rules="operateRules" ref="operateForm" label-width="120px">
<el-form-item label="开卷机编号" prop="porIdx">
<el-input v-model="operateMatForm.porIdx"></el-input>
</el-form-item>
<el-form-item label="卷取机编号" prop="trIdx">
<el-input v-model="operateMatForm.trIdx"></el-input>
</el-form-item>
<el-form-item label="计划id" prop="planId">
<el-input v-model="operateMatForm.planId" placeholder="请输入计划ID"></el-input>
</el-form-item>
<el-form-item label="钢卷号" prop="entryMatId">
<el-input v-model="operateMatForm.entryMatId" placeholder="请输入钢卷号"></el-input>
</el-form-item>
<!-- <el-form-item label="计划号" prop="planNo">
<el-input v-model="operateMatForm.planNo" placeholder="请输入计划号"></el-input>
</el-form-item> -->
<el-form-item label="操作类型" prop="operation">
<el-select v-model="operateMatForm.operation" disabled>
<el-option label="钢卷上线" value="ONLINE"></el-option>
<el-option label="手动卸卷" value="UNLOAD"></el-option>
<el-option label="整卷回退" value="ALL_RETURN"></el-option>
<el-option label="半卷回退" value="HALF_RETURN"></el-option>
<el-option label="卸卷并封闭" value="BLOCK"></el-option>
<!-- <el-option label="甩尾" value="THROW_TAIL"></el-option> -->
</el-select>
</el-form-item>
<!-- 回退相关字段 -->
<template v-if="['ALL_RETURN', 'HALF_RETURN'].includes(operateMatForm.operation)">
<el-form-item label="回退卷号" prop="returnMatId">
<el-input v-model="operateMatForm.returnMatId" placeholder="请输入回退卷号"></el-input>
</el-form-item>
<el-form-item label="回退重量" prop="returnWeight">
<el-input v-model="operateMatForm.returnWeight" placeholder="请输入回退重量"></el-input>
</el-form-item>
<el-form-item label="回退备注" prop="returnRemark">
<el-input v-model="operateMatForm.returnRemark" rows="3"></el-input>
</el-form-item>
</template>
<!-- 产出长度字段 -->
<template v-if="['PRODUCING', 'PRODUCT'].includes(operateMatForm.operation)">
<el-form-item label="产出钢卷长度" prop="coilLength">
<el-input v-model="operateMatForm.coilLength" type="number" placeholder="请输入产出钢卷长度"></el-input>
</el-form-item>
</template>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="operateMatStatus = false">取消</el-button>
<el-button type="primary" @click="submitOperateForm">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { adjustPosition, getTrackMatPosition, operateMat, getBackData, getPlanQueue } from '@/api/l2/track'
import wsManager from '@/utils/websocketManager'
// 基于后端 DeviceEnum 的前端映射(区域、来源、参数字段)
const DEVICE_META = {
POR1: { sectionType: 'ENTRY', sourceType: 'ENTRY', paramFields: ['tensionPorBr1', 'stripSpeed'] },
POR2: { sectionType: 'ENTRY', sourceType: 'ENTRY', paramFields: ['tensionPorBr2', 'stripSpeed'] },
WELDER: { sectionType: 'ENTRY', sourceType: 'ENTRY', paramFields: ['weldStatus'] },
ENL1: { sectionType: 'PROCESS', sourceType: 'ENTRY', paramFields: ['celLength', 'celCapacity', 'tensionCel'] },
ENL2: { sectionType: 'PROCESS', sourceType: 'ENTRY', paramFields: ['celLength', 'celCapacity', 'tensionCel'] },
CLEAN: { sectionType: 'PROCESS', sourceType: 'FURNACE', paramFields: ['cleaningVoltage', 'cleaningCurrent', 'alkaliConcentration', 'alkaliTemperature'] },
FUR1: { sectionType: 'PROCESS', sourceType: 'FURNACE', paramFields: ['phfExitStripTemp', 'potTemperature', 'gasConsumption'] },
FUR2: { sectionType: 'PROCESS', sourceType: 'FURNACE', paramFields: ['rtfExitStripTemp', 'zincPotPower'] },
FUR3: { sectionType: 'PROCESS', sourceType: 'FURNACE', paramFields: ['jcsExitStripTemp', 'coolingTowerStripTemp'] },
FUR4: { sectionType: 'PROCESS', sourceType: 'FURNACE', paramFields: ['scsExitStripTemp'] },
POT: { sectionType: 'PROCESS', sourceType: 'COAT', paramFields: ['scsExitStripTemp'] },
TOWER: { sectionType: 'PROCESS', sourceType: 'COAT', paramFields: ['scsExitStripTemp'] },
TM: { sectionType: 'PROCESS', sourceType: 'COAT', paramFields: ['tensionBr5Tm', 'stripSpeedTmExit'] },
TL: { sectionType: 'PROCESS', sourceType: 'COAT', paramFields: ['tlElongation', 'tensionTlBr7'] },
COAT: { sectionType: 'PROCESS', sourceType: 'COAT', paramFields: [
'avrCoatingWeightTop','stdCoatingWeightTop','maxCoatingWeightTop','minCoatingWeightTop',
'avrCoatingWeightBottom','stdCoatingWeightBottom','maxCoatingWeightBottom','minCoatingWeightBottom',
'airKnifePressure','airKnifeFlow','airKnifeGap','stripSpeedTmExit','tensionBr5Tm',
'tensionTmBr6','tensionBr5Br6','tmMask','tmElongation','rollForceOperator','rollForceDrive',
'motorTorque','bendingForce','antiCrimpingRollMesh','billyRollMesh',
'tensionTlBr7','tensionBr6Br7','tlFlag','tlElongation','levelingUnit1Mesh','levelingUnit2Mesh',
'antiCrossBowUnitMesh','tensionBr7Br8','stripSpeedAfp','stripTempAfp'
] },
CXL1: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: ['cxlLength', 'cxlCapacity', 'tensionCxl'] },
CXL2: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: ['cxlLength', 'cxlCapacity', 'tensionCxl'] },
INS: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: ['inspectionStatus'] },
TR: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: ['coilLength', 'speedExitSection', 'tensionBr9Tr'] },
EXC: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: [] },
WEIGHT: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: [] },
WEIT: { sectionType: 'EXIT', sourceType: 'EXIT', paramFields: ['coilLength', 'speedExitSection'] }
}
const PARAM_LABELS = {
tensionPorBr1: '开卷张力1#',
tensionPorBr2: '开卷张力2#',
stripSpeed: '带钢速度',
weldStatus: '焊机状态',
celLength: '入口活套位置',
celCapacity: '入口活套套量',
tensionCel: '入口活套张力',
cleaningVoltage: '清洗电压',
cleaningCurrent: '清洗电流',
alkaliConcentration: '碱液浓度',
alkaliTemperature: '碱液温度',
phfExitStripTemp: 'PH炉出口温度',
potTemperature: '锌锅温度',
gasConsumption: '燃气消耗',
rtfExitStripTemp: '加热段出口温度',
zincPotPower: '锌锅功率',
jcsExitStripTemp: '冷却段出口温度',
coolingTowerStripTemp: '冷却塔温度',
scsExitStripTemp: '均衡段出口温度',
tensionBr5Tm: 'BR5-TM张力',
stripSpeedTmExit: 'TM出口速度',
tmElongation: '光整延伸率',
tensionTlBr7: 'TL-BR7张力',
tlElongation: '拉矫延伸率',
cxlLength: '出口活套位置',
cxlCapacity: '出口活套套量',
tensionCxl: '出口活套张力',
inspectionStatus: '检查状态',
coilLength: '钢卷长度',
speedExitSection: '出口段速度',
tensionBr9Tr: 'BR9-TR张力',
avrCoatingWeightTop: '顶部涂重',
avrCoatingWeightBottom: '底部涂重'
}
const FIELD_ALIASES = {
phfExitStripTemp: ['phFurnaceTemperatureActual', 'phFurnaceTemperature'],
rtfExitStripTemp: ['nof1FurnaceTemperatureActual', 'nof1FurnaceTemperatureSet'],
jcsExitStripTemp: ['jcsExitStripTemp'],
scsExitStripTemp: ['scsExitStripTemp'],
potTemperature: ['potTemperature'],
zincPotPower: ['zincPotPower'],
gasConsumption: ['gasConsumption'],
coolingTowerStripTemp: ['coolingTowerTemperature', 'coolingTowerStripTemp'],
cleaningVoltage: ['cleaningVoltage'],
cleaningCurrent: ['cleaningCurrent'],
alkaliConcentration: ['alkaliConcentration'],
alkaliTemperature: ['alkaliTemperature']
}
const CLEANING_FIELDS = new Set(['cleaningVoltage', 'cleaningCurrent', 'alkaliConcentration', 'alkaliTemperature'])
export default {
data() {
return {
// WebSocket 连接状态
socketStatus: {
measure: false,
position: false,
signal: false,
matmap: false,
calcSetup: false
},
matMapList: [],
selectedCard: null,
// 实时测量数据track_measure
realtimeData: {
entry: null,
furnace: null,
coat: null,
exit: null
},
// 位置追踪数据track_position
positionData: {
coilStripLocationList: [],
entryLoopLen: 0,
exitLoopLen: 0,
entryLoopPer: 0,
exitLoopLPer: 0,
porId: null,
trId: null,
entrySpeed: 0,
technologySpeed: 0,
exitSpeed: 0,
matMapList: [],
},
// 操作信号数据track_signal
signalData: null,
// 上一次收到的操作类型(用于检测连续生产中)
lastSignalOperation: null,
// 计算设定结果calc_setup_result
calcSetupResult: null,
showCalcResultDialog: false,
// 加载状态
isLoading: true,
// 计划队列tab
activeQueueTab: 'all',
// 生产计划队列
planQueue: [],
// 选中的生产计划
selectedPlan: null,
adjustForm: {
current: null,
target: null
},
adjustMode: false,
operateMatForm: {
porIdx: null,
trIdx: null,
planId: '',
entryMatId: '',
// planNo: '',
operation: '',
returnMatId: '',
returnWeight: null,
returnRemark: '',
coilLength: null
},
operateRules: {
planId: [{ required: true, message: '请输入计划id', trigger: 'blur' }],
entryMatId: [{ required: true, message: '请输入钢卷号', trigger: 'blur' }],
operation: [{ required: true, message: '请选择操作类型', trigger: 'change' }],
returnMatId: [
{
required: true,
message: '请输入回退卷号',
trigger: 'blur',
validator: (rule, val, cb) => {
if (['ALL_RETURN', 'HALF_RETURN'].includes(this.operateMatForm.operation) && !val) {
cb(new Error('请输入回退卷号'))
} else cb()
}
}
],
returnWeight: [
{
required: true,
message: '请输入回退重量',
trigger: 'blur',
validator: (rule, val, cb) => {
if (['ALL_RETURN', 'HALF_RETURN'].includes(this.operateMatForm.operation) && (val === null || val === '')) {
cb(new Error('请输入回退重量'))
} else cb()
}
}
]
},
operateMatStatus: false, // 操作对话框显示状态
returnInfo: {},
isLoadingReturn: false,
returnError: ''
}
},
computed: {
// 当前计划ONLINE 或 PRODUCING状态
currentPlans() {
return this.planQueue.filter(plan =>
['ONLINE', 'PRODUCING', 'PRODUCTING'].includes(plan.status)
)
},
// 当前生产中的计划(取状态为生产中的首条)
producingPlan() {
return this.planQueue.find(plan => ['PRODUCING', 'PRODUCTING'].includes(plan.status))
},
// 当前生产中的钢卷号
producingCoilId() {
if (this.producingPlan) {
return this.producingPlan.coilid || this.producingPlan.matId || ''
}
return ''
},
// 显示的计划列表根据tab切换
displayedPlans() {
if (this.activeQueueTab === 'current') {
return this.currentPlans
}
return this.planQueue
},
// 选中计划的位置信息
selectedPlanPosition() {
if (!this.selectedPlan || !this.matMapList || this.matMapList.length === 0) {
return null
}
// 从matMapList中查找匹配的位置
// matMapList中的字段是驼峰命名planId, matId
// 计划对象中的字段可能是planid, coilid
const position = this.matMapList.find(item =>
(item.planId && item.planId === this.selectedPlan.planid) ||
(item.planId && item.planId === this.selectedPlan.planId) ||
(item.matId && item.matId === this.selectedPlan.coilid) ||
(item.matId && item.matId === this.selectedPlan.matId)
)
return position
},
getOperateTitle() {
const titleMap = {
'ONLINE': '钢卷上线',
'UNLOAD': '手动卸卷',
'ALL_RETURN': '整卷回退',
'HALF_RETURN': '半卷回退',
'BLOCK': '卸卷并封闭',
'THROW_TAIL': '甩尾'
}
return titleMap[this.operateMatForm.operation] || '钢卷操作'
},
// 分段汇总数据
entrySectionMetrics() {
return this.buildSectionMetrics('ENTRY', ['stripSpeed', 'tensionPorBr1', 'tensionPorBr2', 'celLength', 'celCapacity'])
},
furnaceSectionMetrics() {
return this.buildSectionMetrics('FURNACE', [
'cleaningVoltage', 'cleaningCurrent', 'alkaliConcentration', 'alkaliTemperature',
'phfExitStripTemp', 'rtfExitStripTemp', 'jcsExitStripTemp', 'scsExitStripTemp',
'potTemperature', 'zincPotPower', 'gasConsumption', 'coolingTowerStripTemp'
])
},
coatSectionMetrics() {
return this.buildSectionMetrics('COAT', ['stripSpeedTmExit', 'avrCoatingWeightTop', 'avrCoatingWeightBottom', 'tmElongation', 'tlElongation'])
},
exitSectionMetrics() {
return this.buildSectionMetrics('EXIT', ['speedExitSection', 'coilLength', 'cxlLength', 'cxlCapacity', 'tensionBr9Tr'])
},
// 入口段设备列表(依据 DeviceEnum.sectionType
entryDevicesList() {
return this.getDevicesBySection('ENTRY')
},
// 熔炉段设备列表PROCESS 且来源 FURNACE
furnaceDevicesList() {
return this.getDevicesBySource('FURNACE')
},
// 涂层段设备列表PROCESS 且来源 COAT
coatDevicesList() {
return this.getDevicesBySource('COAT')
},
// 出口段设备列表EXIT常规设备
exitDevicesList() {
const exitDevices = this.getDevicesBySection('EXIT')
return exitDevices.filter(d => !['INS', 'EXC', 'WEIGHT'].includes(d.positionNameEn))
},
// 出口其他设备列表
exitOtherDevicesList() {
return this.getDevicesBySection('EXIT').filter(d => ['INS', 'EXC', 'WEIGHT'].includes(d.positionNameEn))
},
// 根据选中设备获取详细数据
currentDeviceData() {
if (!this.selectedCard) return []
const deviceId = this.selectedCard.positionNameEn
const meta = DEVICE_META[deviceId]
if (!meta) return []
// 针对开卷机/入口活套/出口活套,先校验卷号匹配
if (!this.matchReel(deviceId)) return []
const source = this.getRealtimeSourceData(deviceId)
if (!source) return []
const data = []
// 通用字段:按 DeviceEnum.paramFields 取值
meta.paramFields.forEach(field => {
const value = this.getFieldValueWithAlias(source, field)
if (value !== null && value !== undefined && value !== '') {
data.push({
label: PARAM_LABELS[field] || field,
value: this.formatValue(value),
unit: this.getFieldUnit(field)
})
}
})
return data
}
},
methods: {
// ============ 辅助:设备元数据 ============
getDevicesBySection(sectionType) {
return this.matMapList.filter(d => DEVICE_META[d.positionNameEn]?.sectionType === sectionType)
},
getDevicesBySource(sourceType) {
return this.matMapList.filter(d => DEVICE_META[d.positionNameEn]?.sourceType === sourceType)
},
// 判断设备是否有物料(用于绿框)
hasMatId(deviceId) {
const info = this.getDeviceInfo(deviceId)
return !!(info && info.matId)
},
getRealtimeSourceData(deviceId) {
const sourceType = DEVICE_META[deviceId]?.sourceType
if (!sourceType) return null
if (sourceType === 'ENTRY') return this.realtimeData.entry
if (sourceType === 'FURNACE') return this.realtimeData.furnace
if (sourceType === 'COAT') return this.realtimeData.coat
if (sourceType === 'EXIT') return this.realtimeData.exit
return null
},
getFieldValueWithAlias(source, field) {
// 兼容后端字段命名差异(如大小写、别名)
if (!source) return null
// 按别名映射
if (FIELD_ALIASES[field]) {
for (const alias of FIELD_ALIASES[field]) {
if (alias in source) return source[alias]
}
}
if (field in source) return source[field]
const lower = field.charAt(0).toLowerCase() + field.slice(1)
if (lower in source) return source[lower]
return null
},
getFieldUnit(field) {
const unitMap = {
stripSpeed: 'm/min',
stripSpeedTmExit: 'm/min',
stripSpeedAfp: 'm/min',
celLength: 'm',
cxlLength: 'm',
celCapacity: '%',
cxlCapacity: '%',
tensionPorBr1: 'daN',
tensionPorBr2: 'daN',
tensionBr5Tm: 'daN',
tensionTlBr7: 'daN',
tensionCxl: 'daN',
coilLength: 'm',
speedExitSection: 'm/min',
tlElongation: '%',
tmElongation: '%',
cleaningVoltage: 'V',
cleaningCurrent: 'A',
alkaliConcentration: 'g/L',
alkaliTemperature: '℃',
phfExitStripTemp: '℃',
rtfExitStripTemp: '℃',
jcsExitStripTemp: '℃',
scsExitStripTemp: '℃',
potTemperature: '℃',
zincPotPower: 'kW',
gasConsumption: 'm³/h'
}
return unitMap[field] || ''
},
getSectionSource(sectionType) {
if (sectionType === 'ENTRY') return this.realtimeData.entry
if (sectionType === 'FURNACE') return this.realtimeData.furnace
if (sectionType === 'COAT') return this.realtimeData.coat
if (sectionType === 'EXIT') return this.realtimeData.exit
return null
},
buildSectionMetrics(sectionType, fields) {
const source = this.getSectionSource(sectionType)
return fields.map(f => {
const raw = source ? this.getFieldValueWithAlias(source, f) : null
const unit = this.getFieldUnit(f)
return {
label: PARAM_LABELS[f] || f,
value: `${this.formatValue(raw)}${unit ? ' ' + unit : ''}`
}
})
},
matchReel(deviceId) {
if (!['POR1', 'POR2', 'ENL1', 'ENL2', 'CXL1', 'CXL2'].includes(deviceId)) return true
if (!this.realtimeData.entry) return false
const reel = this.realtimeData.entry.payOffReelNumber
if ((deviceId === 'POR1' || deviceId === 'ENL1' || deviceId === 'CXL1') && reel === 1) return true
if ((deviceId === 'POR2' || deviceId === 'ENL2' || deviceId === 'CXL2') && reel === 2) return true
return false
},
// ============ WebSocket 连接管理 ============
initWebSockets() {
// 1. track_measure - 实时测量数据
wsManager.connect('track_measure', {
onMessage: this.processMeasureData,
onOpen: () => {
this.socketStatus.measure = true
console.log('✅ 测量数据连接成功')
},
onError: () => { this.socketStatus.measure = false },
onClose: () => { this.socketStatus.measure = false }
})
// 2. track_position - 钢卷位置追踪
wsManager.connect('track_position', {
onMessage: this.processPositionData,
onOpen: () => {
this.socketStatus.position = true
console.log('✅ 位置追踪连接成功')
},
onError: () => { this.socketStatus.position = false },
onClose: () => { this.socketStatus.position = false }
})
// 3. track_signal - 操作信号
wsManager.connect('track_signal', {
onMessage: this.processSignalData,
onOpen: () => {
this.socketStatus.signal = true
console.log('✅ 操作信号连接成功')
},
onError: () => { this.socketStatus.signal = false },
onClose: () => { this.socketStatus.signal = false }
})
// 4. track_matmap - 物料位置映射
wsManager.connect('track_matmap', {
onMessage: this.processMatmapData,
onOpen: () => {
this.socketStatus.matmap = true
console.log('✅ 物料映射连接成功')
},
onError: () => { this.socketStatus.matmap = false },
onClose: () => { this.socketStatus.matmap = false }
})
// 5. calc_setup_result - 计算设定结果
wsManager.connect('calc_setup_result', {
onMessage: this.processCalcSetupResult,
onOpen: () => {
this.socketStatus.calcSetup = true
console.log('✅ 计算结果连接成功')
},
onError: () => { this.socketStatus.calcSetup = false },
onClose: () => { this.socketStatus.calcSetup = false }
})
},
disconnectWebSockets() {
wsManager.disconnectAll()
this.socketStatus = {
measure: false,
position: false,
signal: false,
matmap: false,
calcSetup: false
}
},
// ============ 数据处理方法 ============
processMeasureData(data) {
if (data.appMeasureEntryMessage) {
this.$set(this.realtimeData, 'entry', data.appMeasureEntryMessage)
}
if (data.appMeasureFurnaceMessage) {
this.$set(this.realtimeData, 'furnace', data.appMeasureFurnaceMessage)
}
if (data.appMeasureCoatMessage) {
this.$set(this.realtimeData, 'coat', data.appMeasureCoatMessage)
}
if (data.appMeasureExitMessage) {
this.$set(this.realtimeData, 'exit', data.appMeasureExitMessage)
}
this.$forceUpdate()
},
processPositionData(data) {
this.positionData = {
coilStripLocationList: data.coilStripLocationList || [],
entryLoopLen: data.entryLoopLen || 0,
exitLoopLen: data.exitLoopLen || 0,
entryLoopPer: data.entryLoopPer || 0,
exitLoopLPer: data.exitLoopLPer || 0,
porId: data.porId,
trId: data.trId,
entrySpeed: data.entrySpeed || 0,
technologySpeed: data.technologySpeed || 0,
exitSpeed: data.exitSpeed || 0,
matMapList: data.matMapList || []
}
this.$forceUpdate()
},
processSignalData(data) {
// 先记录是否为连续生产中信号
const repeatProducing =this.lastSignalOperation === 'PRODUCING' && data.operation === 'PRODUCING'
this.signalData = data
const operationText = this.getOperationText(data.operation)
const autoFlagText = data.autoFlag === 1 ? '[手动]' : '[自动]'
const config = this.getOperationConfig(data.operation)
// 检测到上线、生产中、生产完成等关键操作时,刷新生产计划队列
if (['ONLINE', 'PRODUCING', 'PRODUCT'].includes(data.operation) ) {
console.log(`检测到${operationText}操作,刷新生产计划队列...`)
this.refreshPlanQueue()
}
// 右上角通知(所有操作都显示)
if(!repeatProducing){
this.$notify({
title: `${config.icon} ${config.title}`,
message: `${autoFlagText} ${operationText}\n钢卷号: ${data.entryMatId}\n计划ID: ${data.planId || '-'}`,
type: config.type,
duration: config.duration,
position: 'top-right',
showClose: true
})
}
// 如果需要弹窗确认(甩尾等重要操作)
if (config.needAlert) {
this.$alert(
`钢卷号: ${data.entryMatId}\n计划ID: ${data.planId || '-'}\n操作类型: ${operationText}\n操作方式: ${autoFlagText}`,
`${config.icon} ${config.title}`,
{
confirmButtonText: '知道了',
type: config.type,
center: true
}
)
}
// 如果物料映射 WebSocket 未连接,则刷新数据
if (!this.socketStatus.matmap) {
this.fetchData()
}
// 记录本次操作类型
this.lastSignalOperation = data.operation
},
processMatmapData(data) {
if (Array.isArray(data)) {
this.matMapList = data
this.$forceUpdate()
}
},
processCalcSetupResult(data) {
this.calcSetupResult = data
if (data.flag) {
this.$message.success(`计算完成 (Key: ${data.key})`)
this.showCalcResultDialog = true
} else {
this.$message.error(`计算失败 (Key: ${data.key})`)
}
},
// ============ 辅助方法 ============
getOperationText(operation) {
const operationMap = {
ONLINE: '钢卷上线',
UNLOAD: '卸卷',
PRODUCING: '生产中',
PRODUCT: '生产完成',
PAY_OVER: '甩尾',
THROW_TAIL: '甩尾',
ALL_RETURN: '整卷回退',
HALF_RETURN: '半卷回退',
BLOCK: '卸卷并封闭'
}
return operationMap[operation] || operation
},
// 获取操作类型配置
getOperationConfig(operation) {
const configs = {
ONLINE: {
icon: '🎬',
title: '钢卷上线通知',
type: 'success',
duration: 4000,
needAlert: false
},
UNLOAD: {
icon: '📤',
title: '卸卷操作通知',
type: 'info',
duration: 3000,
needAlert: false
},
PRODUCING: {
icon: '⚙️',
title: '生产状态变更',
type: 'success',
duration: 3000,
needAlert: false
},
PRODUCT: {
icon: '✅',
title: '生产完成通知',
type: 'success',
duration: 4000,
needAlert: false
},
PAY_OVER: {
icon: '⚠️',
title: '甩尾操作提示',
type: 'warning',
duration: 5000,
needAlert: true
},
THROW_TAIL: {
icon: '⚠️',
title: '甩尾操作提示',
type: 'warning',
duration: 5000,
needAlert: true
},
ALL_RETURN: {
icon: '↩️',
title: '整卷回退通知',
type: 'warning',
duration: 4000,
needAlert: false
},
HALF_RETURN: {
icon: '↩️',
title: '半卷回退通知',
type: 'warning',
duration: 4000,
needAlert: false
},
BLOCK: {
icon: '🚫',
title: '封闭操作通知',
type: 'warning',
duration: 3000,
needAlert: false
}
}
return configs[operation] || {
icon: '📢',
title: '操作通知',
type: 'info',
duration: 3000,
needAlert: false
}
},
// ============ API 方法 ============
// 刷新生产计划队列(独立方法,可被信号触发)
async refreshPlanQueue() {
try {
// 只查询活动状态的计划,排除已完成和甩尾
const statuses = ['ONLINE', 'PRODUCING', 'READY', 'NEW']
const allPlans = []
// 并行查询所有状态
const requests = statuses.map(status =>
getPlanQueue({ status }).catch(err => {
console.warn(`查询${status}状态失败:`, err)
return { code: 500, data: [] }
})
)
const results = await Promise.all(requests)
// 合并所有查询结果
results.forEach((res, index) => {
if (res.code === 200 && res.data) {
allPlans.push(...res.data)
}
})
// 去重根据id
const uniquePlans = Array.from(
new Map(allPlans.map(plan => [plan.id, plan])).values()
)
// 排除已完成和甩尾的计划
const excludeStatuses = ['PRODUCT', 'PAY_OVER', 'COMPLETED', 'CANCELLED', 'UNLOAD']
const activePlans = uniquePlans.filter(plan =>
!excludeStatuses.includes(plan.status)
)
// 定义状态优先级
const statusPriority = {
'PRODUCING': 1,
'ONLINE': 2,
'READY': 3,
'NEW': 4
}
// 排序
const sortedPlans = activePlans.sort((a, b) => {
const priorityA = statusPriority[a.status] || 500
const priorityB = statusPriority[b.status] || 500
if (priorityA !== priorityB) {
return priorityA - priorityB
}
return (a.seqid || 0) - (b.seqid || 0)
})
// 最多显示 5 个计划
this.planQueue = sortedPlans.slice(0, 5)
console.log('查询到的总计划数:', uniquePlans.length, '条')
console.log('活动计划数:', activePlans.length, '条')
console.log('显示的计划:', this.planQueue.length, '条', this.planQueue)
} catch (err) {
console.error('获取生产计划队列失败:', err)
this.planQueue = this.generateMockPlanQueue()
}
},
async fetchData() {
try {
this.isLoading = true
const res = await getTrackMatPosition()
// 只在 WebSocket 未连接时使用 API 数据
if (!this.socketStatus.matmap) {
this.matMapList = res.data.matMapList || []
}
// 获取生产计划队列
await this.refreshPlanQueue()
} catch (err) {
console.error('获取钢卷数据失败:', err)
this.$message.error('获取数据失败,请重试')
} finally {
// 延迟关闭加载状态,确保界面渲染完成
setTimeout(() => {
this.isLoading = false
}, 500)
}
},
// 生成模拟计划队列数据(降级方案)
generateMockPlanQueue() {
// 从物料映射中提取有钢卷的数据作为计划队列
const plansFromMap = this.matMapList
.filter(item => item.matId)
.map((item, index) => ({
planid: item.planId || `PLAN_${index + 1}`,
coilid: item.matId,
steelGrade: `Q235-${index + 1}`,
status: index === 0 ? 'PRODUCING' : (index === 1 ? 'READY' : 'NEW'),
seqid: index + 1
}))
return plansFromMap.slice(0, 5) // 最多显示5个计划
},
// 获取计划状态文本
getPlanStatusText(status) {
const statusMap = {
NEW: '新建',
READY: '就绪',
ONLINE: '上线',
PRODUCING: '生产中',
PRODUCT: '生产完成',
PAY_OVER: '甩尾',
UNLOAD: '卸卷',
COMPLETED: '已完成',
CANCELLED: '已取消'
}
return statusMap[status] || status || '未知'
},
// 获取计划状态标签类型
getPlanStatusTagType(status) {
const typeMap = {
NEW: 'info',
READY: 'primary',
ONLINE: 'warning',
PRODUCING: 'success',
PRODUCTING: 'success', // 兼容带T的写法
PRODUCT: 'info',
PAY_OVER: '',
UNLOAD: 'danger',
COMPLETED: 'info',
CANCELLED: 'danger'
}
return typeMap[status] || 'info'
},
// 获取计划卡片样式类
getPlanItemClass(plan) {
const classes = []
if (plan.status === 'PRODUCING' || plan.status === 'PRODUCTING') {
classes.push('plan-producing')
}
if (plan.status === 'ONLINE') {
classes.push('plan-online')
}
if (plan.status === 'READY') {
classes.push('plan-ready')
}
if (this.selectedPlan && this.selectedPlan.id === plan.id) {
classes.push('plan-selected')
}
return classes
},
// 获取计划序号样式类
getPlanOrderClass(status) {
if (status === 'PRODUCING' || status === 'PRODUCTING') return 'order-producing'
if (status === 'ONLINE') return 'order-online'
if (status === 'READY') return 'order-ready'
if (status === 'NEW') return 'order-new'
return ''
},
// 根据设备ID获取设备信息
getDeviceInfo(deviceId) {
return this.matMapList.find(item => item.positionNameEn === deviceId)
},
// 选择设备
selectDevice(deviceId) {
const deviceInfo = this.getDeviceInfo(deviceId)
if (deviceInfo) {
this.selectedCard = this.selectedCard === deviceInfo ? null : deviceInfo
if (this.selectedCard) {
this.adjustForm.current = this.selectedCard.positionNameEn
} else {
this.adjustForm.current = null
}
// 选择设备时清除选中的计划
this.selectedPlan = null
}
},
// 选择生产计划
selectPlan(plan) {
if (this.selectedPlan && this.selectedPlan.id === plan.id) {
this.selectedPlan = null
} else {
this.selectedPlan = plan
// 选择计划时清除选中的设备
this.selectedCard = null
// 调试:查找并显示位置信息
const position = this.matMapList.find(item =>
(item.planId && item.planId === plan.planid) ||
(item.planId && item.planId === plan.planId) ||
(item.matId && item.matId === plan.coilid) ||
(item.matId && item.matId === plan.matId)
)
if (position) {
console.log('✅ 找到计划位置:', position.positionNameCn, '(', position.positionNameEn, ')')
console.log('匹配详情 - matId:', position.matId, 'planId:', position.planId)
} else {
console.log('⚠️ 未找到计划位置')
console.log('matMapList有', this.matMapList.length, '个设备:', this.matMapList)
console.log('计划信息 - planid:', plan.planid, 'planId:', plan.planId, 'coilid:', plan.coilid, 'matId:', plan.matId)
}
}
},
// 格式化日期时间
formatDateTime(dateTime) {
if (!dateTime) return '-'
const date = new Date(dateTime)
if (isNaN(date.getTime())) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
},
// 获取操作标签类型
getOperationTagType(operation) {
const typeMap = {
ONLINE: 'success',
UNLOAD: 'info',
PRODUCING: 'success',
PRODUCT: 'success',
PAY_OVER: 'warning',
THROW_TAIL: 'warning',
ALL_RETURN: 'warning',
HALF_RETURN: 'warning',
BLOCK: 'danger'
}
return typeMap[operation] || 'info'
},
// 获取信号面板样式类
getSignalPanelClass(operation) {
if (operation === 'PAY_OVER' || operation === 'THROW_TAIL') {
return 'signal-warning'
}
if (operation === 'ONLINE') {
return 'signal-success'
}
if (operation === 'PRODUCT') {
return 'signal-complete'
}
return ''
},
// 获取操作文本样式类
getOperationTextClass(operation) {
if (operation === 'PAY_OVER' || operation === 'THROW_TAIL') {
return 'text-warning'
}
if (operation === 'ONLINE' || operation === 'PRODUCING' || operation === 'PRODUCT') {
return 'text-success'
}
return ''
},
// 判断设备是否有实时数据(基于 sourceType & 卷号匹配)
hasRealtimeData(deviceId) {
const source = this.getRealtimeSourceData(deviceId)
if (!source) return false
if (!this.matchReel(deviceId)) return false
const meta = DEVICE_META[deviceId]
if (!meta) return false
return meta.paramFields.some(f => {
const val = this.getFieldValueWithAlias(source, f)
return val !== null && val !== undefined
})
},
// 判断设备是否工作中(统一依据 paramFields 有效数值/状态)
isDeviceWorking(deviceId) {
const info = this.getDeviceInfo(deviceId)
if (info?.matId) return true
const source = this.getRealtimeSourceData(deviceId)
const meta = DEVICE_META[deviceId]
if (!source || !meta || !this.matchReel(deviceId)) return false
return meta.paramFields.some(f => {
const val = this.getFieldValueWithAlias(source, f)
if (val === null || val === undefined) return false
if (typeof val === 'number') return val > 0
if (typeof val === 'string') return val !== '' && val !== '--'
return true
})
},
// 获取设备状态文本
getDeviceStatus(deviceId) {
const deviceInfo = this.getDeviceInfo(deviceId)
// 如果有物料映射的钢卷号,显示钢卷号
if (deviceInfo?.matId) {
return deviceInfo.matId
}
// 根据实时数据判断工作状态
if (this.isDeviceWorking(deviceId)) {
return '工作中'
}
return '空闲'
},
/**
* 获取回退信息
* @param {Number} posIdx - 位置索引接口必填query参数
*/
fetchReturnData(posIdx) {
// 1. 校验posIdx
if (!posIdx && posIdx !== 0) {
this.returnInfo = {};
this.returnError = '缺少位置索引posIdx无法获取回退信息';
return;
}
// 2. 加载状态初始化
this.isLoadingReturn = true;
this.returnError = '';
// 3. 调用回退接口posIdx作为query参数传递
getBackData({ posIdx })
.then(res => {
this.isLoadingReturn = false;
// 接口成功且有数据
if (res.code === 200 && res.data) {
this.returnInfo = res.data;
} else {
this.returnInfo = {};
this.returnError = res.msg || '获取回退信息失败';
}
})
.catch(err => {
this.isLoadingReturn = false;
this.returnInfo = {};
this.returnError = '获取回退信息出错,请重试';
console.error('回退信息接口异常:', err);
});
},
// 格式化数值
formatValue(value) {
if (value === null || value === undefined) return '0.00'
if (typeof value === 'number') return value.toFixed(2)
return value
},
// 确认调整位置
handleConfirmAdjust() {
const { current, target } = this.adjustForm
if (!current || !target) {
this.$message.warning('请选择当前位置和目标位置')
return
}
const params = {
currentPos: current,
targetPos: target,
}
this.$confirm(`确定将 ${current} 的钢卷调整到 ${target}`, '确认调整', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
adjustPosition(params).then(() => {
this.$message.success('调整成功')
this.fetchData()
}).catch(err => {
console.error('调整失败:', err)
this.$message.error('调整失败,请重试')
})
}).catch(() => {
this.$message.info('已取消调整')
})
},
// 打开操作对话框
handleOperate(row, operation) {
this.$refs.operateForm?.resetFields()
console.log(this.selectCard, row.posIdx)
this.operateMatForm = {
porIdx: row.posIdx.toString() || '',
trIdx: row.posIdx.toString() || '',
planId: row.planId || '',
entryMatId: row.matId || '',
planNo: row.planNo || '',
operation: operation,
returnMatId: '',
returnWeight: null,
returnRemark: '',
coilLength: null
}
this.operateMatStatus = true
},
// 提交操作表单
submitOperateForm() {
this.$refs.operateForm.validate(valid => {
if (valid) {
operateMat(this.operateMatForm).then(() => {
this.$message.success('操作成功')
this.operateMatStatus = false
this.fetchData()
}).catch(err => {
console.error('操作失败:', err)
this.$message.error('操作失败,请重试')
})
} else {
this.$message.warning('请完善表单信息')
}
})
}
},
async mounted() {
await this.fetchData()
this.initWebSockets()
},
beforeDestroy() {
this.disconnectWebSockets()
}
}
</script>
<style scoped>
.device-layout {
background: #f5f5f5;
border-radius: 4px;
padding: 15px;
height: calc(100vh - 60px);
overflow-y: auto;
}
/* 分区区域 */
.section-area {
margin-bottom: 20px;
background: #fafafa;
border-radius: 6px;
padding: 12px;
border: 1px solid #e8e8e8;
}
.section-header {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #ddd;
}
/* 设备网格 */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
/* 设备卡片 */
.device-card {
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
min-height: 120px;
}
.device-card:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
transform: translateY(-2px);
}
.device-card.active {
border-color: #409eff;
background: #ecf5ff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.device-card.has-data {
border-color: #67c23a;
}
.device-card.has-data:hover {
border-color: #67c23a;
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
.device-card.has-data.active {
border-color: #409eff;
background: #ecf5ff;
}
.device-name {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.device-code {
font-size: 11px;
color: #999;
margin-bottom: 6px;
}
.device-status {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.coil-id {
font-size: 12px;
font-weight: 500;
}
.status-working {
color: #67c23a;
}
.status-idle {
color: #909399;
}
.realtime-indicator {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.mini-data {
font-size: 11px;
color: #666;
margin-bottom: 3px;
display: flex;
justify-content: space-between;
}
.mini-label {
color: #999;
}
.mini-value {
color: #67c23a;
font-weight: 500;
}
.section-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin: 8px 0 14px;
}
.summary-item {
background: linear-gradient(135deg, #f7f9fc 0%, #eef2f7 100%);
border: 1px solid #e0e6ed;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.summary-label {
color: #606266;
font-weight: 600;
}
.summary-value {
color: #1f2d3d;
font-weight: 700;
}
/* 段落色系 */
.entry-area .section-header { border-bottom-color: #409eff; }
.entry-area .summary-value { color: #409eff; }
.furnace-area .section-header { border-bottom-color: #e6a23c; }
.furnace-area .summary-value { color: #e6a23c; }
.coat-area .section-header { border-bottom-color: #67c23a; }
.coat-area .summary-value { color: #67c23a; }
.exit-area .section-header { border-bottom-color: #909399; }
.exit-area .summary-value { color: #909399; }
.panel {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
background: #fff;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.info-table {
width: 100%;
font-size: 13px;
}
.info-table tr {
border-bottom: 1px solid #f5f5f5;
}
.info-table tr:last-child {
border-bottom: none;
}
.info-table td {
padding: 8px 0;
line-height: 1.6;
}
.info-table td:first-child {
color: #666;
width: 80px;
}
.info-table td:last-child {
color: #333;
font-weight: 500;
}
.empty-msg {
text-align: center;
padding: 30px;
color: #999;
font-size: 13px;
}
.realtime-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.data-item {
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 12px;
text-align: center;
background: #fafafa;
}
.data-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.data-value {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
font-family: 'Arial', sans-serif;
}
.data-unit {
font-size: 12px;
color: #999;
}
.btn-list {
display: flex;
flex-wrap: wrap;
}
.action-btn {
margin-bottom: 8px;
width: 80px;
height: 40px;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
color: #409eff;
font-size: 16px;
}
.loading-container .el-icon-loading {
font-size: 40px;
margin-bottom: 15px;
animation: rotating 2s linear infinite;
}
@keyframes rotating {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 生产计划队列面板 */
.plan-queue-panel {
background: #fff;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
/* 队列头部 */
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
}
.queue-title {
font-size: 14px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 6px;
}
.queue-title i {
font-size: 16px;
color: #409eff;
}
/* Tab切换按钮组 */
.queue-tabs {
display: flex;
gap: 0;
}
.queue-tab {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
color: #606266;
background: transparent;
border-bottom: 2px solid transparent;
user-select: none;
}
.queue-tab:hover {
color: #409eff;
}
.queue-tab.active {
color: #409eff;
font-weight: 600;
border-bottom-color: #409eff;
}
.queue-tab i {
font-size: 13px;
}
.queue-tab .tab-badge {
margin-left: 4px;
}
.plan-queue-panel .panel-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* 右侧纵向生产计划列表 */
.plan-list-vertical {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-item-vertical {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px;
background: #f7f9fc;
transition: all 0.2s ease;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-item-vertical:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
transform: translateY(-1px);
}
.plan-item-vertical.plan-producing {
background: #f0f9ff;
border-color: #67c23a;
animation: pulse-green 1.5s infinite;
}
.plan-item-vertical.plan-online {
background: #fdf6ec;
border-color: #e6a23c;
}
.plan-item-vertical.plan-ready {
background: #ecf5ff;
border-color: #409eff;
}
.plan-item-vertical.plan-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.plan-item-top {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
}
.plan-order-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #fff;
background: #909399;
flex-shrink: 0;
}
.plan-order-dot.order-producing { background: #67c23a; animation: blink-green-dot 1.2s infinite; }
.plan-order-dot.order-online { background: #e6a23c; }
.plan-order-dot.order-ready { background: #409eff; }
.plan-order-dot.order-new { background: #909399; }
.plan-title-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #303133;
}
.plan-id { font-weight: 600; }
.plan-coil { color: #606266; }
.plan-item-bottom {
display: flex;
gap: 16px;
font-size: 12px;
color: #606266;
}
.plan-queue-panel .panel-title i {
margin-right: 8px;
color: #409eff;
}
.plan-queue-list {
max-height: none;
overflow-y: visible;
padding: 15px 20px;
}
.plan-items {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 5px;
}
.plan-item {
min-width: 220px;
background: #f5f7fa;
border: 2px solid #e4e7ed;
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
transition: all 0.3s;
cursor: pointer;
}
.plan-item:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
transform: translateY(-2px);
}
.plan-item.plan-producing {
background: #f0f9ff;
border-color: #67c23a;
animation: pulse-green 1.5s infinite;
}
.plan-item.plan-online {
background: #fdf6ec;
border-color: #e6a23c;
}
.plan-item.plan-ready {
background: #ecf5ff;
border-color: #409eff;
}
.plan-item.plan-selected {
background: #f0f9ff;
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
/* 绿色闪烁动画 */
@keyframes pulse-green {
0%, 100% {
border-color: #67c23a;
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.4);
}
50% {
border-color: #85ce61;
box-shadow: 0 0 0 4px rgba(103, 194, 58, 0.6), 0 0 15px rgba(103, 194, 58, 0.4);
}
}
.plan-order {
width: 28px;
height: 28px;
background: #909399;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.plan-order.order-producing {
background: #67c23a;
animation: blink-green-dot 1.2s infinite;
}
.plan-order.order-online {
background: #e6a23c;
}
.plan-order.order-ready {
background: #409eff;
}
.plan-order.order-new {
background: #909399;
}
/* 绿色点闪烁动画 */
@keyframes blink-green-dot {
0%, 100% {
background: #67c23a;
box-shadow: 0 0 5px rgba(103, 194, 58, 0.5);
}
50% {
background: #85ce61;
box-shadow: 0 0 10px rgba(103, 194, 58, 0.8);
}
}
.plan-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.plan-row {
display: flex;
align-items: center;
font-size: 13px;
}
.plan-label {
color: #909399;
min-width: 60px;
flex-shrink: 0;
}
.plan-value {
color: #303133;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-status {
display: flex;
justify-content: flex-end;
}
/* WebSocket 状态栏 */
.ws-status-bar {
display: flex;
gap: 15px;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
margin-bottom: 10px;
align-items: center;
}
.ws-status-bar i {
font-size: 18px;
color: #606266;
cursor: pointer;
}
/* 分区头部增强 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-info {
font-size: 13px;
color: #67c23a;
font-weight: 500;
}
/* 信号信息面板 */
.signal-info {
padding: 10px 0;
}
.signal-info.signal-warning {
background: #fef0f0;
padding: 15px;
border-radius: 4px;
border: 1px solid #fde2e2;
}
.signal-info.signal-success {
background: #f0f9ff;
padding: 15px;
border-radius: 4px;
border: 1px solid #c6e2ff;
}
.signal-info.signal-complete {
background: #f0f9ff;
padding: 15px;
border-radius: 4px;
border: 1px solid #b3e19d;
}
.signal-detail {
margin-top: 10px;
font-size: 13px;
line-height: 2;
}
.signal-detail div {
padding: 4px 0;
}
.text-warning {
color: #e6a23c;
font-weight: 600;
font-size: 14px;
}
.text-success {
color: #67c23a;
font-weight: 600;
font-size: 14px;
}
/* 计算结果头部 */
.calc-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
font-size: 14px;
}
/* 生产计划详情 */
.plan-detail-content {
overflow-y: auto;
}
/* 位置信息提示卡片 */
.position-alert {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
margin-bottom: 15px;
background: #f0f9ff;
border: 1px solid #b3d8ff;
border-left: 4px solid #409eff;
border-radius: 4px;
}
.position-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #409eff;
border-radius: 50%;
color: #fff;
font-size: 20px;
flex-shrink: 0;
}
.position-info {
flex: 1;
}
.position-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.position-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 2px;
}
.position-code {
font-size: 12px;
color: #606266;
font-family: 'Courier New', monospace;
}
.plan-time-info {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #ebeef5;
}
.info-subtitle {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 10px;
}
.panel-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-title i {
margin-right: 5px;
}
</style>