Files
klp-mono/apps/hand-factory/components/lines/zinc1.vue

1414 lines
39 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>
<view class="page-container">
<!-- 简洁标签栏 -->
<view class="tab-container">
<view
v-for="item in tabData"
:key="item.value"
@click="currentTab = item.value"
class="tab-item"
:class="{ 'tab-active': currentTab === item.value }"
>
<text class="tab-label">{{ item.text }}</text>
<view class="tab-indicator" v-if="currentTab === item.value"></view>
</view>
</view>
<!-- 刷新按钮 -->
<view class="refresh-btn-fixed" @click="refreshAll">
<text class="refresh-icon" :class="{ rotating: isRefreshing }"></text>
</view>
<!-- Tab1实时监控 -->
<scroll-view
scroll-y
class="scroll-container"
v-if="currentTab === 1"
>
<!-- 顶部状态栏 -->
<view class="status-bar">
<view class="status-item">
<text class="status-label">Socket</text>
<text class="status-value" :class="isConnected ? 'status-通畅' : 'status-异常'">{{ isConnected ? '已连接' : '未连接' }}</text>
</view>
<view class="status-divider"></view>
<view class="status-item">
<text class="status-label">更新时间</text>
<text class="status-value status-time">{{ lastUpdateTime }}</text>
</view>
<view class="status-divider"></view>
<view class="status-item">
<text class="status-label">设备数</text>
<text class="status-value">{{ deviceDefs.length }}</text>
</view>
<view class="status-divider"></view>
<view class="status-item status-toggle" @click="toggleChartPause">
<text class="status-label">折线图</text>
<text
class="status-value status-time"
:class="isChartPaused ? 'status-异常' : 'status-通畅'"
>
{{ isChartPaused ? '已暂停刷新' : '实时刷新中' }}
</text>
</view>
</view>
<!-- 实时数据卡片 + 统计图按酸轧页风格不按设备分块 -->
<view class="section">
<view class="section-title">入口段实时</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in entryMetrics" :key="'entry_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('ENTRY', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('entry')" :opts="lineChartOpts" />
</view>
</view>
<view class="section">
<view class="section-title">退火炉段实时</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in furnaceMetrics" :key="'furnace_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('FURNACE', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('furnace')" :opts="lineChartOpts" />
</view>
</view>
<view class="section">
<view class="section-title">后处理/涂层段实时</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in coatMetrics" :key="'coat_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('COAT', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('coat')" :opts="lineChartOpts" />
</view>
</view>
<view class="section">
<view class="section-title">出口段实时</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in exitMetrics" :key="'exit_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('EXIT', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('exit')" :opts="lineChartOpts" />
</view>
</view>
</scroll-view>
<!-- 生产统计 / 实绩 -->
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 2">
<view class="section">
<view class="section-title">当前生产情况</view>
<view class="chart-box" v-if="currentPlan">
<view class="stats-table">
<view class="stats-header">
<text class="stats-col">当前钢卷</text>
<text class="stats-col">钢种</text>
<text class="stats-col">规格 (×)</text>
<text class="stats-col">计划长度</text>
<text class="stats-col">计划重量</text>
</view>
<view class="stats-row">
<text class="stats-col">{{ currentPlan.exitMatId || '—' }}</text>
<text class="stats-col">{{ currentPlan.steelGrade || '—' }}</text>
<text class="stats-col">
{{ formatNum(currentPlan.exitThickness) }} × {{ formatNum(currentPlan.exitWidth) }}
</text>
<text class="stats-col">{{ formatNum(currentPlan.exitLength) }}</text>
<text class="stats-col">{{ formatNum(currentPlan.exitWeight) }}</text>
</view>
</view>
<view class="history-tip" v-if="!currentPlan">
<text>暂无当前生产计划数据</text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">生产实绩汇总</view>
<view class="overview-grid" v-if="reportSummary">
<view class="overview-card">
<text class="overview-label">钢卷总数</text>
<text class="overview-value">{{ reportSummary.coilCount || 0 }}</text>
<text class="overview-unit"></text>
</view>
<view class="overview-card">
<text class="overview-label">总实际重量</text>
<text class="overview-value">{{ formatNum(reportSummary.totalActualWeight) }}</text>
<text class="overview-unit">t</text>
</view>
<view class="overview-card">
<text class="overview-label">成材率</text>
<text class="overview-value">
{{ reportSummary.yieldRate != null ? (reportSummary.yieldRate * 100).toFixed(1) : '—' }}
</text>
<text class="overview-unit">%</text>
</view>
</view>
<view class="chart-box" v-else>
<text class="placeholder-text">暂无实绩汇总数据</text>
</view>
</view>
<view class="section">
<view class="section-title">生产实绩明细</view>
<view class="stats-table" v-if="reportDetails && reportDetails.length">
<view class="stats-header">
<text class="stats-col">成品卷号</text>
<text class="stats-col">原料卷号</text>
<text class="stats-col">/</text>
<text class="stats-col">规格 (×)</text>
<text class="stats-col">长度/重量</text>
</view>
<view
class="stats-row"
v-for="item in reportDetails"
:key="item.exitMatId + '_' + item.onlineTime"
>
<text class="stats-col">{{ item.exitMatId }}</text>
<text class="stats-col">{{ item.entryMatId }}</text>
<text class="stats-col">{{ item.groupNo || '—' }}/{{ item.shiftNo || '—' }}</text>
<text class="stats-col">
{{ formatNum(item.exitThickness) }} × {{ formatNum(item.exitWidth) }}
</text>
<text class="stats-col">
{{ formatNum(item.exitLength) }} m / {{ formatNum(item.actualWeight) }} t
</text>
</view>
</view>
<view class="chart-box" v-else>
<text class="placeholder-text">暂无生产明细记录</text>
</view>
</view>
</scroll-view>
<!-- 停机统计 / 停机记录 -->
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 3">
<view class="section">
<view class="section-title">停机记录</view>
<!-- 月份选择器 -->
<view class="stoppage-filter">
<picker mode="date" fields="month" :value="stoppageMonth" @change="onStoppageMonthChange">
<view class="stoppage-month-btn">
<text class="stoppage-month-text">{{ stoppageMonthLabel }}</text>
</view>
</picker>
</view>
<view class="stats-table" v-if="stoppageList && stoppageList.length">
<view class="stats-header">
<text class="stats-col">开始时间</text>
<text class="stats-col">结束时间</text>
<text class="stats-col">持续时间</text>
<text class="stats-col">区域/设备</text>
<text class="stats-col">原因</text>
</view>
<view
class="stats-row"
v-for="item in stoppageList"
:key="item.stopid"
>
<text class="stats-col">{{ item.startDate }}</text>
<text class="stats-col">{{ item.endDate }}</text>
<text class="stats-col">{{ item.duration }}</text>
<text class="stats-col">
{{ item.area || '—' }}/{{ item.seton || '—' }}
</text>
<text class="stats-col">{{ item.remark || '—' }}</text>
</view>
</view>
<view class="chart-box" v-else>
<text class="placeholder-text">暂无停机记录</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { listDeviceEnumAll } from '@/api/pocket/deviceEnum'
import { getDeviceFieldMetaAll } from '@/api/pocket/deviceFieldMeta'
import { listDeviceSnapshotLatest } from '@/api/pocket/deviceSnapshot'
import { getCurrentPlan, getCurrentProcess } from '@/api/business/dashboard'
import { getReportSummary, getReportDetails } from '@/api/business/report'
import { listStoppage } from '@/api/business/stoppage'
import { createMeasureSocket } from '@/utils/socketMeasure'
export default {
data() {
return {
currentTab: 1,
tabData: [
{ text: '实时监控', value: 1 },
{ text: '生产统计', value: 2 },
{ text: '停机统计', value: 3 }
],
isRefreshing: false,
isConnected: false,
lastUpdateTime: '—',
// 折线图实时刷新开关(避免查看某个点时被新数据刷掉)
isChartPaused: false,
// socket最新消息
latestMeasure: null,
// 设备定义(后端 DeviceEnum 全量)
deviceDefs: [],
// 字段元数据fieldName -> {label, unit, description}
fieldMeta: {},
// device_field_trend: deviceCode -> { fieldName -> trendDTO }
fieldTrendMap: {},
// 懒加载:已订阅的设备列表
subscribedDeviceCodes: [],
// 实时数据卡片定义(按酸轧页组织,不按设备分块)
entryMetrics: [
{ field: 'entryCoilId', label: '入口卷号', unit: '' },
{ field: 'stripLocation', label: '带钢位置', unit: 'm' },
{ field: 'stripSpeed', label: '带钢速度', unit: 'm/min' },
{ field: 'tensionPorBr1', label: '入口张力 POR-BR1', unit: 'daN' },
{ field: 'tensionBr1Br2', label: '张力 BR1-BR2', unit: 'daN' },
{ field: 'tensionBr2Br3', label: '张力 BR2-BR3', unit: 'daN' }
],
furnaceMetrics: [
{ field: 'phFurnaceTemperatureActual', label: 'PH炉温', unit: '℃' },
{ field: 'rtf1FurnaceTemperatureActual', label: 'RTF1炉温', unit: '℃' },
{ field: 'potTemperature', label: '锌锅温度', unit: '℃' },
{ field: 'potPower', label: '锌锅功率', unit: '' }
],
coatMetrics: [
{ field: 'avrCoatingWeightTop', label: '上层平均涂层重量', unit: 'g/m²' },
{ field: 'avrCoatingWeightBottom', label: '下层平均涂层重量', unit: 'g/m²' },
{ field: 'airKnifePressure', label: '气刀压力', unit: '' },
{ field: 'stripSpeedTmExit', label: 'TM出口速度', unit: 'm/min' }
],
exitMetrics: [
{ field: 'tensionBr8Br9', label: '张力 BR8-BR9', unit: 'daN' },
{ field: 'tensionBr9Tr', label: '张力 BR9-TR', unit: 'daN' },
{ field: 'speedExitSection', label: '出口速度', unit: 'm/min' },
{ field: 'coilLength', label: '钢卷长度', unit: 'm' }
],
// 前端历史缓存(打开页面即可出趋势)
chartMaxPoints: 60,
chartSeries: {
entry: { time: [], stripSpeed: [], tensionPorBr1: [], tensionBr1Br2: [], tensionBr2Br3: [] },
furnace: { time: [], phFurnaceTemperatureActual: [], rtf1FurnaceTemperatureActual: [], potTemperature: [] },
coat: { time: [], avrCoatingWeightTop: [], avrCoatingWeightBottom: [], airKnifePressure: [], stripSpeedTmExit: [] },
exit: { time: [], tensionBr8Br9: [], tensionBr9Tr: [], speedExitSection: [] }
},
// 生产统计(实绩)
currentPlan: null,
currentProcess: null,
reportSummary: null,
reportDetails: [],
reportLoading: false,
// 停机记录
stoppageList: [],
stoppageLoading: false,
stoppageMonth: '', // yyyy-MM
lineChartOpts: {
color: ['#0066cc', '#409eff', '#66b1ff', '#a0cfff', '#d9ecff', '#ecf5ff'],
padding: [15, 15, 0, 15],
enableScroll: false,
legend: { show: true, position: 'top', fontSize: 10, lineHeight: 14, itemGap: 6 },
dataLabel: false,
dataPointShape: false,
xAxis: { disableGrid: true, rotateLabel: true, itemCount: 5, labelCount: 5, fontSize: 10 },
yAxis: {
gridType: 'dash',
dashLength: 4,
gridColor: '#e4e7ed',
showTitle: true,
fontSize: 10,
data: [{ title: '数值' }]
},
extra: { line: { type: 'curve', width: 2, activeType: 'hollow' } }
},
socketClient: null,
fieldTrendSocket: null,
fieldTrendSubscribed: false
}
},
mounted() {
this.initSocket()
Promise.all([this.loadDeviceDefs(), this.loadFieldMeta(), this.loadHistoryForCharts()]).then(() => {})
this.updateLastTime()
// 周期性从后端快照刷新折线图数据(降低刷新频率,避免频繁打断用户查看)
// 这里用 60 秒刷一次,后端快照是每 5 分钟一条,折线图变化会比较平滑
this._historyTimer = setInterval(() => {
if (!this.isChartPaused) {
this.loadHistoryForCharts()
}
}, 60 * 1000)
},
computed: {
stoppageMonthLabel() {
if (!this.stoppageMonth) return '选择月份'
const parts = (this.stoppageMonth || '').split('-')
if (parts.length < 2) return this.stoppageMonth
const [y, m] = parts
return `${y}${m}`
}
},
watch: {
currentTab(newVal) {
if (newVal === 2) {
this.loadReportData()
} else if (newVal === 3) {
// 默认当前月
if (!this.stoppageMonth) {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
this.stoppageMonth = `${y}-${m}`
}
this.loadStoppageData()
}
}
},
beforeDestroy() {
if (this.socketClient) this.socketClient.close()
if (this.fieldTrendSocket) this.fieldTrendSocket.close()
if (this._historyTimer) {
clearInterval(this._historyTimer)
this._historyTimer = null
}
},
methods: {
async loadDeviceDefs() {
const res = await listDeviceEnumAll()
// 打印响应,排查是否拿到 data 数组(避免页面空白)
try {
console.log(res)
} catch (e) {
// ignore
}
this.deviceDefs = (res && res.data) || []
},
async loadFieldMeta() {
const res = await getDeviceFieldMetaAll()
// 打印响应,排查字段元数据是否加载成功
try {
const keys = res && res.data && typeof res.data === 'object' ? Object.keys(res.data).length : -1
console.log('[resp]/api/deviceFieldMeta/all:', {
code: res && res.code,
msg: res && res.msg,
dataType: res && res.data ? Object.prototype.toString.call(res.data) : 'null',
keyCount: keys
})
} catch (e) {
// ignore
}
this.fieldMeta = (res && res.data) || {}
},
// 加载历史快照,填充折线图的“历史部分”
async loadHistoryForCharts() {
try {
// 使用设备快照作为折线图唯一数据源,避免每条 socket 消息都触发重绘
// 每次加载前先清空本地缓存,防止重复累加
this.chartSeries = {
entry: { time: [], stripSpeed: [], tensionPorBr1: [], tensionBr1Br2: [], tensionBr2Br3: [] },
furnace: { time: [], phFurnaceTemperatureActual: [], rtf1FurnaceTemperatureActual: [], potTemperature: [] },
coat: { time: [], avrCoatingWeightTop: [], avrCoatingWeightBottom: [], airKnifePressure: [], stripSpeedTmExit: [] },
exit: { time: [], tensionBr8Br9: [], tensionBr9Tr: [], speedExitSection: [] }
}
const limit = this.chartMaxPoints
const tasks = []
const deviceMap = {
entry: { deviceCode: 'POR1', fields: ['stripSpeed', 'tensionPorBr1', 'tensionBr1Br2', 'tensionBr2Br3'] },
furnace: {
deviceCode: 'FUR2',
fields: ['phFurnaceTemperatureActual', 'rtf1FurnaceTemperatureActual', 'potTemperature']
},
coat: {
deviceCode: 'COAT',
fields: ['avrCoatingWeightTop', 'avrCoatingWeightBottom', 'airKnifePressure', 'stripSpeedTmExit']
},
exit: { deviceCode: 'TR', fields: ['tensionBr8Br9', 'tensionBr9Tr', 'speedExitSection'] }
}
Object.keys(deviceMap).forEach((group) => {
const cfg = deviceMap[group]
tasks.push(
listDeviceSnapshotLatest({ limit, deviceCode: cfg.deviceCode })
.then((res) => {
const rows = (res && res.data) || []
// 按时间升序
const list = rows.slice().reverse()
list.forEach((row) => {
const t = (row.createTime || '').slice(11, 19) // HH:mm:ss
let snap = {}
try {
snap = row.snapshotData ? JSON.parse(row.snapshotData) : {}
} catch (e) {
snap = {}
}
const values = {}
cfg.fields.forEach((f) => {
values[f] = snap[f]
})
this.pushSeries(group, t, values)
})
})
.catch(() => {})
)
})
await Promise.all(tasks)
} catch (e) {
// ignore
}
},
initSocket() {
// 实时测量数据 WebSocket
this.socketClient = createMeasureSocket({
type: 'track_measure',
onOpen: () => {
console.log('初始化socket成功')
this.isConnected = true
},
onClose: () => {
this.isConnected = false
},
onError: () => {
this.isConnected = false
},
onMessage: (data) => {
try {
// 兼容字符串 / 已解析对象两种情况,并打日志方便排查
let payload = null
if (typeof data === 'string') {
payload = JSON.parse(data)
} else if (data && typeof data === 'object') {
// uni 有时会包一层 { data: 'xxx' }
if (typeof data.data === 'string') {
payload = JSON.parse(data.data)
} else {
payload = data
}
}
if (!payload) return
this.latestMeasure = payload
// 实时卡片仍然使用最新测量值,折线图改为走后端快照,避免高频刷新
this.updateLastTime()
} catch (e) {
console.error('解析 track_measure 数据失败:', e, data)
}
}
})
this.socketClient.connect()
// 点位趋势 WebSocket懒加载订阅
this.fieldTrendSocket = createMeasureSocket({
type: 'device_field_trend',
onOpen: () => {
console.log('点位趋势 WebSocket 连接成功')
// 初次连接后先不订阅,等滚动/展开触发
this.fieldTrendSubscribed = true
this.sendFieldTrendSubscribe([])
},
onClose: () => {
console.log('点位趋势 WebSocket 连接关闭')
this.fieldTrendSubscribed = false
},
onError: () => {
console.error('点位趋势 WebSocket 连接错误')
this.fieldTrendSubscribed = false
},
onMessage: (data) => {
try {
const trend = JSON.parse(data)
if (!trend || !trend.deviceCode || !trend.fieldName) return
if (!this.fieldTrendMap[trend.deviceCode]) {
this.$set(this.fieldTrendMap, trend.deviceCode, {})
}
this.$set(this.fieldTrendMap[trend.deviceCode], trend.fieldName, trend)
} catch (e) {
console.error('解析点位趋势数据失败:', e)
}
}
})
this.fieldTrendSocket.connect()
},
// 加载生产统计 / 实绩数据
async loadReportData() {
if (this.reportLoading) return
this.reportLoading = true
try {
// 当前计划 & 当前工艺
try {
const planRes = await getCurrentPlan()
this.currentPlan = (planRes && planRes.data) || null
} catch (e) {
this.currentPlan = null
}
try {
const procRes = await getCurrentProcess()
this.currentProcess = (procRes && procRes.data) || null
} catch (e) {
this.currentProcess = null
}
// 实绩汇总 & 明细:不传筛选条件,后端按默认时间范围处理
try {
const sumRes = await getReportSummary({})
this.reportSummary = sumRes || null
} catch (e) {
this.reportSummary = null
}
try {
const detRes = await getReportDetails({})
this.reportDetails = (detRes && Array.isArray(detRes) ? detRes : []) || []
} catch (e) {
this.reportDetails = []
}
} finally {
this.reportLoading = false
}
},
// 加载停机记录
async loadStoppageData() {
if (this.stoppageLoading) return
this.stoppageLoading = true
try {
// 计算当月起止日期yyyy-MM-dd
const month = this.stoppageMonth
let startDate = ''
let endDate = ''
if (month) {
const [y, m] = month.split('-').map((s) => Number(s))
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const pad = (n) => String(n).padStart(2, '0')
startDate = `${first.getFullYear()}-${pad(first.getMonth() + 1)}-${pad(first.getDate())}`
endDate = `${last.getFullYear()}-${pad(last.getMonth() + 1)}-${pad(last.getDate())}`
}
const res = await listStoppage({ startDate, endDate })
this.stoppageList = (res && res.data) || []
} catch (e) {
this.stoppageList = []
} finally {
this.stoppageLoading = false
}
},
onStoppageMonthChange(e) {
// e.detail.value 为 yyyy-MM-dd这里截取前 7 位当作月份
const v = e.detail && e.detail.value
this.stoppageMonth = v ? v.slice(0, 7) : ''
this.loadStoppageData()
},
// 滚动事件:节流触发订阅更新
onScroll() {
if (this._scrollTimer) return
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null
this.updateVisibleDeviceSubscriptions()
}, 200)
},
// 计算当前可视区域内的设备,并向后端发送订阅(懒加载)
updateVisibleDeviceSubscriptions() {
const list = this.deviceDefs || []
if (!list.length) return
// 使用 selectorQuery 获取每个设备块的位置
const query = uni.createSelectorQuery().in(this)
list.forEach((d) => {
query.select('#device-' + d.deviceCode).boundingClientRect()
})
query.selectViewport().scrollOffset()
query.exec((res) => {
// res: [rect1, rect2, ..., viewport]
if (!res || res.length < 2) return
const viewport = res[res.length - 1] || {}
const scrollTop = viewport.scrollTop || 0
const windowHeight = viewport.windowHeight || 0
// 预加载阈值:提前 0.5 屏开始订阅
const preload = windowHeight * 0.5
const bottom = scrollTop + windowHeight + preload
// 找到“已经进入/即将进入”的最大索引
let maxIdx = -1
for (let i = 0; i < list.length; i++) {
const rect = res[i]
if (!rect) continue
const top = rect.top + scrollTop
if (top <= bottom) {
maxIdx = i
}
}
// 订阅规则:当前可视(含预加载)位置的设备及其以前全部设备
const nextList = maxIdx >= 0 ? list.slice(0, maxIdx + 1).map((d) => d.deviceCode) : []
if (JSON.stringify(nextList) === JSON.stringify(this.subscribedDeviceCodes || [])) return
this.subscribedDeviceCodes = nextList
this.sendFieldTrendSubscribe(nextList)
})
},
getFieldLabel(field) {
const meta = this.fieldMeta[field]
return (meta && meta.label) || field
},
getFieldUnit(field) {
const meta = this.fieldMeta[field]
return (meta && meta.unit) || ''
},
sendFieldTrendSubscribe(deviceCodes) {
if (!this.fieldTrendSocket || !this.fieldTrendSubscribed) return
this.fieldTrendSocket.send({
lazy: true,
deviceCodes: deviceCodes || []
})
},
// 折线图数据转换(仅结构转换,不做计算)
toLineChart(trend, fieldName) {
if (!trend) return { categories: [], series: [] }
return {
categories: trend.categories || [],
series: [
{
name: this.getFieldLabel(fieldName),
data: trend.data || []
}
]
}
},
// 根据 sourceType + 字段名取实时值(不按设备分块)
getRealtimeValueBySource(sourceType, field) {
const m = this.latestMeasure || {}
let msg = {}
switch (sourceType) {
case 'ENTRY':
msg = m.appMeasureEntryMessage || {}
break
case 'FURNACE':
msg = m.appMeasureFurnaceMessage || {}
break
case 'COAT':
msg = m.appMeasureCoatMessage || {}
break
case 'EXIT':
msg = m.appMeasureExitMessage || {}
break
default:
msg = {}
}
const v = msg ? msg[field] : null
if (typeof v === 'string') return v || '—'
return this.formatValue(v)
},
// 追加一帧到前端历史缓存(用于统计折线图)
appendChartPoint(payload) {
const now = new Date()
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
const ss = String(now.getSeconds()).padStart(2, '0')
const t = `${hh}:${mm}:${ss}`
const entry = payload.appMeasureEntryMessage || {}
const furnace = payload.appMeasureFurnaceMessage || {}
const coat = payload.appMeasureCoatMessage || {}
const exit = payload.appMeasureExitMessage || {}
this.pushSeries('entry', t, {
stripSpeed: entry.stripSpeed,
tensionPorBr1: entry.tensionPorBr1,
tensionBr1Br2: entry.tensionBr1Br2,
tensionBr2Br3: entry.tensionBr2Br3
})
this.pushSeries('furnace', t, {
phFurnaceTemperatureActual: furnace.phFurnaceTemperatureActual,
rtf1FurnaceTemperatureActual: furnace.rtf1FurnaceTemperatureActual,
potTemperature: furnace.potTemperature
})
this.pushSeries('coat', t, {
avrCoatingWeightTop: coat.avrCoatingWeightTop,
avrCoatingWeightBottom: coat.avrCoatingWeightBottom,
airKnifePressure: coat.airKnifePressure,
stripSpeedTmExit: coat.stripSpeedTmExit
})
this.pushSeries('exit', t, {
tensionBr8Br9: exit.tensionBr8Br9,
tensionBr9Tr: exit.tensionBr9Tr,
speedExitSection: exit.speedExitSection
})
},
pushSeries(group, time, values) {
const g = this.chartSeries[group]
if (!g) return
g.time.push(time)
Object.keys(values).forEach((k) => {
if (!g[k]) return
const raw = values[k]
const num = raw === null || raw === undefined || raw === '' ? null : Number(raw)
g[k].push(Number.isFinite(num) ? num : null)
})
if (g.time.length > this.chartMaxPoints) {
g.time.shift()
Object.keys(values).forEach((k) => {
if (g[k] && g[k].length > this.chartMaxPoints) g[k].shift()
})
}
},
// 生成多序列折线图数据
toGroupLineChart(group) {
const g = this.chartSeries[group]
if (!g) return { categories: [], series: [] }
const seriesKeys = Object.keys(g).filter((k) => k !== 'time')
return {
categories: g.time || [],
series: seriesKeys.map((k) => ({
name: this.getFieldLabel(k),
data: g[k] || []
}))
}
},
// 切换折线图实时刷新状态
toggleChartPause() {
this.isChartPaused = !this.isChartPaused
},
formatNum(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '—'
return Number(v).toFixed(2)
},
// 不再按设备分块展示pickSourceMsg/getRealtimeFieldValue 已弃用
// 快捷导航(已移除)
async refreshAll() {
if (this.isRefreshing) return
this.isRefreshing = true
try {
await Promise.all([this.loadDeviceDefs(), this.loadFieldMeta()])
// 刷新后更新订阅(按滚动懒加载逻辑)
this.$nextTick(() => {
this.updateVisibleDeviceSubscriptions()
})
this.updateLastTime()
} finally {
this.isRefreshing = false
}
},
updateLastTime() {
const now = new Date()
const hour = String(now.getHours()).padStart(2, '0')
const minute = String(now.getMinutes()).padStart(2, '0')
const second = String(now.getSeconds()).padStart(2, '0')
this.lastUpdateTime = `${hour}:${minute}:${second}`
},
formatTime(str) {
if (!str) return ''
const d = new Date(str)
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
},
formatValue(v) {
if (v === null || v === undefined || v === '') return '—'
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
},
}
}
</script>
<style scoped lang="scss">
.page-container {
min-height: 100vh;
background: #f5f7fa;
}
.tab-container {
display: flex;
background: #fff;
border-bottom: 2rpx solid #e4e7ed;
}
.tab-item {
flex: 1;
text-align: center;
padding: 28rpx 0;
position: relative;
.tab-label {
font-size: 28rpx;
color: #606266;
font-weight: 400;
}
&.tab-active {
.tab-label {
color: #0066cc;
font-weight: 500;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 3rpx;
background: #0066cc;
}
}
.refresh-btn-fixed {
position: fixed;
right: 32rpx;
bottom: 120rpx;
width: 96rpx;
height: 96rpx;
background: #0066cc;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(0, 102, 204, 0.4);
z-index: 999;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.refresh-icon {
font-size: 48rpx;
color: #fff;
display: block;
line-height: 1;
&.rotating {
animation: rotate 1s linear infinite;
}
}
/* 快速导航菜单(固定在左下角,模仿 acidity.vue */
.nav-menu-fixed {
position: fixed;
left: 32rpx;
bottom: 120rpx;
z-index: 998;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12rpx;
}
.nav-toggle {
width: 96rpx;
height: 96rpx;
background: #409eff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(64, 158, 255, 0.4);
transition: all 0.3s ease;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.nav-toggle-icon {
font-size: 48rpx;
color: #fff;
display: block;
line-height: 1;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 8rpx;
animation: slideUp 0.3s ease;
max-height: 60vh;
overflow: auto;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.nav-item {
background: #fff;
border: 2rpx solid #409eff;
border-radius: 8rpx;
padding: 16rpx 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.2);
transition: all 0.2s ease;
&:active {
background: #f0f9ff;
transform: scale(0.95);
}
}
.nav-label {
font-size: 26rpx;
color: #409eff;
font-weight: 500;
white-space: nowrap;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.scroll-container {
height: calc(100vh - 96rpx);
padding: 24rpx;
}
.placeholder-box {
background: #fff;
border: 1rpx dashed #dcdfe6;
border-radius: 12rpx;
padding: 28rpx 20rpx;
margin-bottom: 18rpx;
}
.placeholder-text {
font-size: 24rpx;
color: #909399;
}
.status-bar {
display: flex;
align-items: center;
background: #fff;
padding: 24rpx 32rpx;
margin-bottom: 24rpx;
border-radius: 8rpx;
border: 1rpx solid #e4e7ed;
}
.status-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.status-label {
font-size: 26rpx;
color: #909399;
}
.status-value {
font-size: 28rpx;
font-weight: 500;
color: #303133;
&.status-通畅 {
color: #67c23a;
}
&.status-异常 {
color: #f56c6c;
}
&.status-time {
color: #909399;
font-size: 24rpx;
}
}
.status-divider {
width: 1rpx;
height: 40rpx;
background: #e4e7ed;
}
.section {
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 20rpx;
padding-left: 16rpx;
border-left: 4rpx solid #0066cc;
}
.device-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 18rpx;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.device-header-left {
display: flex;
flex-direction: column;
}
.device-title {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.device-sub {
font-size: 22rpx;
color: #909399;
margin-top: 6rpx;
}
.device-toggle {
font-size: 24rpx;
color: #0066cc;
font-weight: 500;
}
.metrics-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.metric-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 18rpx 12rpx;
text-align: center;
}
.metric-name {
display: block;
font-size: 22rpx;
color: #909399;
margin-bottom: 12rpx;
}
.metric-value {
display: block;
font-size: 44rpx;
font-weight: 600;
color: #0066cc;
margin-bottom: 8rpx;
line-height: 1;
&.small {
font-size: 34rpx;
}
}
.metric-unit {
display: block;
font-size: 20rpx;
color: #909399;
}
.device-actions {
display: flex;
justify-content: flex-end;
margin-top: 14rpx;
}
.action-btn {
padding: 12rpx 18rpx;
border: 2rpx solid #0066cc;
border-radius: 8rpx;
}
.action-text {
font-size: 24rpx;
color: #0066cc;
font-weight: 500;
}
.history-tip {
font-size: 24rpx;
color: #909399;
margin-bottom: 12rpx;
}
.history-strong {
color: #303133;
font-weight: 600;
}
.field-selector {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
align-items: center;
margin-bottom: 12rpx;
}
.field-chip {
padding: 10rpx 14rpx;
border-radius: 999rpx;
border: 2rpx solid #dcdfe6;
background: #fff;
}
.field-chip.active {
border-color: #0066cc;
background: #f0f9ff;
}
.chip-text {
font-size: 22rpx;
color: #606266;
}
.field-chip.active .chip-text {
color: #0066cc;
font-weight: 500;
}
.field-tip {
font-size: 22rpx;
color: #909399;
margin-left: auto;
}
.chart-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 24rpx 16rpx;
}
.raw-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 24rpx 16rpx;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.overview-card {
background: #fff;
border-radius: 12rpx;
padding: 32rpx 24rpx;
text-align: center;
border: 1rpx solid #e4e7ed;
}
.overview-card:nth-child(2) {
background: #fff;
}
.overview-card:nth-child(3) {
background: #fff;
}
.overview-label {
display: block;
font-size: 24rpx;
color: #909399;
margin-bottom: 16rpx;
}
.overview-value {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.overview-unit {
display: block;
font-size: 22rpx;
color: #909399;
margin-top: 8rpx;
}
.stoppage-filter {
margin: 0 0 20rpx 0;
display: flex;
justify-content: flex-end;
}
.stoppage-month-btn {
padding: 12rpx 24rpx;
border-radius: 999rpx;
border: 1rpx solid #dcdfe6;
background: #fff;
}
.stoppage-month-text {
font-size: 24rpx;
color: #606266;
}
.status-distribution {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.status-item-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 12rpx;
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-type {
font-size: 28rpx;
color: #606266;
font-weight: 500;
}
.status-count {
font-size: 36rpx;
font-weight: 600;
color: #0066cc;
}
.stats-table {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
overflow: hidden;
}
.stats-header {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
background: #f5f7fa;
padding: 20rpx 16rpx;
border-bottom: 1rpx solid #e4e7ed;
}
.stats-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
padding: 20rpx 16rpx;
border-bottom: 1rpx solid #e4e7ed;
}
.stats-row:last-child {
border-bottom: none;
}
.stats-col {
font-size: 24rpx;
color: #606266;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stats-header .stats-col {
font-weight: 600;
color: #303133;
}
</style>