二级后端添加数据快照修正。前端添加数字孪生

This commit is contained in:
2026-01-15 17:37:57 +08:00
parent 166afcb959
commit 3c4e60bc49
20 changed files with 2445 additions and 148 deletions

View File

@@ -19,7 +19,12 @@
<text class="refresh-icon" :class="{ rotating: isRefreshing }"></text>
</view>
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 1" @scroll="onScroll">
<!-- Tab1实时监控 -->
<scroll-view
scroll-y
class="scroll-container"
v-if="currentTab === 1"
>
<!-- 顶部状态栏 -->
<view class="status-bar">
<view class="status-item">
@@ -36,50 +41,213 @@
<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 v-for="dev in deviceDefs" :key="'device_' + dev.deviceCode" :id="'device-' + dev.deviceCode" class="device-section">
<view class="section-title">{{ dev.desc }}{{ dev.deviceCode }}</view>
<view v-if="fieldTrendMap[dev.deviceCode]">
<view v-for="fieldName in (dev.paramFields || [])" :key="dev.deviceCode + '_' + fieldName" class="field-chart-container">
<view v-if="fieldTrendMap[dev.deviceCode][fieldName]">
<view class="chart-header">
<text class="chart-title">{{ getFieldLabel(fieldName) }}</text>
<text class="chart-unit">{{ getFieldUnit(fieldName) }}</text>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toLineChart(fieldTrendMap[dev.deviceCode][fieldName], fieldName)" :opts="lineChartOpts" />
</view>
<view class="stats-grid">
<view class="stat-item"><text class="stat-label">AVG</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].avg) }}</text></view>
<view class="stat-item"><text class="stat-label">MAX</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].max) }}</text></view>
<view class="stat-item"><text class="stat-label">MIN</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].min) }}</text></view>
<view class="stat-item"><text class="stat-label">LAST</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].last) }}</text></view>
</view>
</view>
<view v-else class="placeholder-box">
<text class="placeholder-text">等待 {{ getFieldLabel(fieldName) }} 数据...</text>
</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 v-else class="placeholder-box">
<text class="placeholder-text">等待 {{ dev.desc }} 数据...滚动到此处加载</text>
<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 {
@@ -87,11 +255,15 @@ export default {
return {
currentTab: 1,
tabData: [
{ text: '实时监控', value: 1 }
{ text: '实时监控', value: 1 },
{ text: '生产统计', value: 2 },
{ text: '停机统计', value: 3 }
],
isRefreshing: false,
isConnected: false,
lastUpdateTime: '—',
// 折线图实时刷新开关(避免查看某个点时被新数据刷掉)
isChartPaused: false,
// socket最新消息
latestMeasure: null,
@@ -108,6 +280,55 @@ export default {
// 懒加载:已订阅的设备列表
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],
@@ -129,32 +350,71 @@ export default {
socketClient: null,
fieldTrendSocket: null,
fieldTrendSubscribed: false,
subscribedDeviceCodes: []
fieldTrendSubscribed: false
}
},
mounted() {
this.initSocket()
Promise.all([this.loadDeviceDefs(), this.loadFieldMeta()]).then(() => {
// 页面初始化完成后,默认订阅首屏设备(懒加载)
this.$nextTick(() => {
this.updateVisibleDeviceSubscriptions()
})
})
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) || []
@@ -162,9 +422,82 @@ export default {
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({
@@ -181,10 +514,24 @@ export default {
},
onMessage: (data) => {
try {
this.latestMeasure = JSON.parse(data)
// 兼容字符串 / 已解析对象两种情况,并打日志方便排查
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) {
// ignore
console.error('解析 track_measure 数据失败:', e, data)
}
}
})
@@ -225,6 +572,76 @@ export default {
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
@@ -311,34 +728,116 @@ export default {
}
},
// 根据 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)
},
// 根据 DeviceEnum.sourceType 决定取哪个 message
pickSourceMsg(dev) {
const m = this.latestMeasure || {}
if (!dev || !dev.sourceType) return {}
switch (dev.sourceType) {
case 'ENTRY':
return m.appMeasureEntryMessage || {}
case 'FURNACE':
return m.appMeasureFurnaceMessage || {}
case 'COAT':
return m.appMeasureCoatMessage || {}
case 'EXIT':
return m.appMeasureExitMessage || {}
default:
return {}
}
},
// 不再按设备分块展示pickSourceMsg/getRealtimeFieldValue 已弃用
getRealtimeFieldValue(dev, field) {
const msg = this.pickSourceMsg(dev)
const v = msg ? msg[field] : null
return this.formatValue(v)
},
// 快捷导航(已移除)
@@ -457,6 +956,86 @@ export default {
}
}
/* 快速导航菜单(固定在左下角,模仿 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);
@@ -471,6 +1050,19 @@ export default {
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;
@@ -696,27 +1288,25 @@ export default {
}
.overview-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #fff;
border-radius: 12rpx;
padding: 32rpx 24rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
border: 1rpx solid #e4e7ed;
}
.overview-card:nth-child(2) {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
box-shadow: 0 4rpx 12rpx rgba(245, 87, 108, 0.3);
background: #fff;
}
.overview-card:nth-child(3) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
box-shadow: 0 4rpx 12rpx rgba(79, 172, 254, 0.3);
background: #fff;
}
.overview-label {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
color: #909399;
margin-bottom: 16rpx;
}
@@ -724,17 +1314,35 @@ export default {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #fff;
color: #303133;
line-height: 1.2;
}
.overview-unit {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
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);