Files
klp-oa/klp-ui/src/views/lines/panels/track/index.vue
砂糖 dd4ca3d380 fix: 统一长度单位显示为米并优化CoilInfo组件
将多处长度单位从毫米(mm)改为米(m),保持单位统一
重构CoilInfo组件为动态字段配置模式,提升可维护性
移除未使用的CoilInfoRender组件引用
2026-04-27 10:26:13 +08:00

2375 lines
73 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 + ' m' : '-' }}
</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 createApi from '@/api/l2/track'
import WebSocketManager 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 {
props: {
baseURL: {
type: String,
default: ''
}
},
data() {
return {
// WebSocket 连接状态
fetchApi: {},
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 - 实时测量数据
const wsManager = new WebSocketManager('ws://' + this.baseURL + '/websocket')
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 =>
this.fetchApi.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 this.fetchApi.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参数传递
this.fetchApi.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(() => {
this.fetchApi.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) {
this.fetchApi.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() {
this.fetchApi = createApi(this.baseURL);
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>