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

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

@@ -3,7 +3,8 @@ import request from '@/utils/request'
export function listDeviceEnumAll() {
return request({
url: '/api/deviceEnum/all',
method: 'get'
method: 'get',
baseUrl: 'http://140.143.206.120:10082/prod-api'
})
}

View File

@@ -3,7 +3,8 @@ import request from '@/utils/request'
export function getDeviceFieldMetaAll() {
return request({
url: '/api/deviceFieldMeta/all',
method: 'get'
method: 'get',
baseUrl: 'http://140.143.206.120:10082/prod-api'
})
}

View File

@@ -5,7 +5,8 @@ export function listDeviceSnapshotLatest(params) {
return request({
url: '/api/deviceSnapshot/latest',
method: 'get',
params
params,
baseUrl: 'http://140.143.206.120:10082/prod-api'
})
}
@@ -14,7 +15,8 @@ export function listDeviceSnapshotRange(params) {
return request({
url: '/api/deviceSnapshot/range',
method: 'get',
params
params,
baseUrl: 'http://140.143.206.120:10082/prod-api'
})
}

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 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="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>
<qiun-data-charts type="line" :chartData="toGroupLineChart('entry')" :opts="lineChartOpts" />
</view>
</view>
<view v-else class="placeholder-box">
<text class="placeholder-text">等待 {{ getFieldLabel(fieldName) }} 数据...</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 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 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);

View File

@@ -20,34 +20,54 @@ export function createMeasureSocket({
function connect() {
manualClose = false
// socket = new WebSocket(url)
// 使用 uni.connectSocket 建立连接(部分运行环境可能不支持 WebSocket会直接抛异常
try {
socket = uni.connectSocket({
url,
success() {
console.log('连接成功')
}
})
console.log(socket)
socket.onOpen = () => {
console.log('连接成功')
onOpen && onOpen()
}
socket.onMessage = (evt) => {
onMessage && onMessage(evt.data)
}
socket.onError = (err) => {
console.log('connectSocket 调用成功,等待 onOpen 回调')
},
fail(err) {
console.error('connectSocket 调用失败:', err)
onError && onError(err)
}
})
} catch (err) {
console.error('connectSocket 执行异常(可能环境不支持 WebSocket:', err)
onError && onError(err)
return
}
socket.onClose = (evt) => {
if (!socket) {
console.error('connectSocket 未返回有效 socketTask')
return
}
// 正确的事件注册方式socketTask.onOpen / onMessage / onError / onClose
socket.onOpen((res) => {
console.log('WebSocket 已打开', res)
onOpen && onOpen(res)
})
socket.onMessage((evt) => {
// H5 为 evt.data小程序为 evt.data统一兼容
const data = evt && (evt.data || evt)
onMessage && onMessage(data)
})
socket.onError((err) => {
console.error('WebSocket 发生错误:', err)
onError && onError(err)
})
socket.onClose((evt) => {
console.log('WebSocket 已关闭', evt)
onClose && onClose(evt)
if (!manualClose) {
setTimeout(connect, 3000)
}
}
})
}
function close() {
@@ -59,11 +79,18 @@ export function createMeasureSocket({
}
function send(data) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
} else {
console.error('WebSocket is not open. Cannot send message.');
if (!socket) {
console.error('WebSocket 未初始化,无法发送消息')
return
}
// uni-app 的 socketTask.send 使用对象形式
socket.send({
data: JSON.stringify(data),
fail(err) {
console.error('发送 WebSocket 消息失败:', err)
}
})
}
return { connect, close, send }

View File

@@ -54,6 +54,7 @@
"screenfull": "5.0.2",
"sortablejs": "1.10.2",
"splitpanes": "2.4.1",
"three": "^0.158.0",
"vue": "2.6.12",
"vue-count-to": "1.0.13",
"vue-cropper": "0.5.5",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,278 @@
// 产线配置数据(替代后端 API
// 轨道节点配置说明:
// trackNodes: 轨道节点数组,按顺序连接
// 每个节点包含:
// id: 节点唯一标识
// name: 节点名称(可选,用于显示)
// position: { x, y, z } 节点位置坐标
// width: 轨道宽度可选默认1.2
// waypoints: 中间路径点数组(可选),用于定义从该节点到下一个节点的中间转弯点
// 每个waypoint包含{ x, y, z } 位置坐标
// 示例waypoints: [{ x: 10, y: 0.5, z: 5 }, { x: 15, y: 0.5, z: 5 }]
// 轨道会依次经过:节点 -> waypoint1 -> waypoint2 -> ... -> 下一个节点
export const productionLineConfig = {
name: "产线展示",
description: "基于节点的轨道系统,可手动调节每个节点的位置和高度。",
// 运动扁钢(压钢带)参数
steelFlow: {
count: 4, // 扁钢数量(同时在轨道上运动的条数)
length: 8, // 每条扁钢长度(沿轨道方向)
widthRatio: 0.85, // 扁钢宽度=轨道宽度*比例
thickness: 0.10, // 扁钢厚度
speed: 6 // 运动速度(数值越大越快)
},
// 轨道节点配置 - 按顺序连接,轨道会平滑通过这些节点
// 可以在节点之间添加 waypoints 来定义中间转弯点
trackNodes: [
{ id: "node_1", name: "起点", position: { x: 5.0, y: 5.0, z: 0.0 }, width: 1.8 },
{ id: "node_2", name: "1#开卷机后", position: { x: 23.0, y: 5.2, z: 0.0 }, width: 1.2 },
{ id: "node_3", name: "2#开卷机前", position: { x: 28.0, y: 5.2, z: 0.0 }, width: 1.2 },
{ id: "node_4", name: "2#开卷机后", position: { x: 42.0, y: 5.2, z: 0.0 }, width: 1.2 },
{
id: "node_5",
name: "夹送矫直前",
position: { x: 46, y: 6.0, z: 0.0 },
width: 1.2,
// 示例:从上一个节点到当前节点,经过多个转弯点
// waypoints: [
// { x: -13.0, y: 3.0, z: 0.0 }, // 第一个转弯点
// { x: -12.5, y: 4.5, z: 0.0 } // 第二个转弯点
// ]
},
{ id: "node_6", name: "夹送矫直后", position: { x:55.0, y: 5.5, z: 0.0 }, width: 1.2 },
{
id: "node_7",
name: "清洗段前",
position: { x: 64, y: 2, z: 1.6 },
width: 1.2,
// 从清洗段前到清洗段后的中间转弯点
// 轨道会依次经过node_7 -> waypoint1 -> waypoint2 -> ... -> node_8
waypoints: [
// 在这里添加转弯点,例如:
{ x: 70, y: 2.0, z: 0.0 }, // 转弯点1
{ x: 72, y: 2.0, z: 13.0 }, // 转弯点2
// { x: 90, y: 0.6, z: 12.0 } // 转弯点3
]
},
{
id: "node_8",
name: "清洗段后",
position: { x: 92, y: 0.6, z: 13.6 },
width: 1.2,
// 示例:清洗段内部可能有多次转弯
waypoints: [
// { x: -2.0, y: 0.6, z: 5.0 }, // 转弯点1
// { x: 0.0, y: 0.6, z: 9.0 }, // 转弯点2
// { x: 1.0, y: 0.6, z: 11.5 } // 转弯点3
]
},
{ id: "node_9", name: "炉火段前", position: { x:92, y:3, z: 2.2 }, width: 1.2 },
{ id: "node_10", name: "炉火段后", position: { x: 92, y: 3, z: 2.2 }, width: 1.2,
waypoints: [
{ x: 92.0, y:5, z: 0.0 }, // 转弯点1
{ x: 92.0, y: 5, z: -10.0 }, // 转弯点2
// { x: 1.0, y: 0.6, z: 11.5 } // 转弯点3
]
},
{ id: "node_11", name: "锌锅前", position: { x: 109, y: 3, z: -10.0 }, width: 1.2 },
{ id: "node_12", name: "锌锅后", position: { x: 109, y:7, z: 4.0 }, width: 1.2,
waypoints: [
{ x: 109.0, y: 5, z: 13.0 }, // 转弯点1
]
},
{ id: "node_13", name: "光整机前",position: { x: 127.0, y: 5, z: 15.0 }, width: 1.2 },
{ id: "node_14", name: "光整机后", position: { x: 130.0, y: 5, z: 15.0 }, width: 1.2 },
{ id: "node_15", name: "拉矫机前", position: { x: 134.0, y: 7, z: 15.0 }, width: 1.2 },
{ id: "node_16", name: "拉矫机后", position: { x: 142.0, y: 7, z: 15.0 }, width: 1.2 },
{ id: "node_17", name: "剪切机前", position: { x:148.0, y: 5, z: 15.0 }, width: 1.2 },
{ id: "node_18", name: "剪切机后", position: { x: 158.0, y: 5, z: 15.0 }, width: 1.2 },
{ id: "node_19", name: "卷取机前", position: { x: 160.0, y: 7, z: 15.0 }, width: 1.2 },
{ id: "node_20", name: "终点", position: { x: 165.0, y: 7, z: 15.0 }, width: 1.2 },
{ id: "node_21", name: "结束", position: { x: 170.0, y: 2, z: 15.0 }, width: 1.2 },
],
// 设备配置(独立于轨道)
models: [
// 入口段(示例:开卷机可复用两次)
{
id: "unjcoiler_1",
name: "1#开卷机",
deviceCode: "POR1",
file: "/models/开卷机.glb",
position: { x: -24.0, y: 0.0, z: 0.0 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut:2,
leadIn: 0,
entryOffset: { y: 0 },
exitOffset: { y: 1.2 },
conveyor: { enabled: true, yOffset: 5.5, width: 1.2 }
}
},
{
id: "unjcoiler_2",
name: "2#开卷机",
deviceCode: "POR2",
file: "/models/开卷机.glb",
position: { x: -18.0, y: 0.0, z: 0.0 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut: 6,
leadIn: 1,
entryOffset: { y: 0.2 },
exitOffset: { y: 0.2 },
conveyor: { enabled: true, yOffset: 5.5, width: 1.2 }
}
},
// 夹送矫直
{
id: "pinch_leveler",
name: "夹送矫直机",
// 暂无对应的实时设备编码,可后续按需补充
file: "/models/夹送矫直机.glb",
position: { x: -12.0, y: 0.0, z: 0.0 },
rotation: { x: 0.0, y: 3.1415926, z: 0.0 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut: 0,
leadIn: 2.0,
entryOffset: { y: 0.25 },
exitOffset: { y: 0.25 },
rampLength: 0,
conveyor: { enabled: true, yOffset: 6, width: 1.2 }
}
},
// 清洗/炉火
{
id: "clean",
name: "清洗段",
deviceCode: "CLEAN",
file: "/models/清洗段.glb",
position: { x: -6, y: 0.0, z: 5.6 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut: 8.0,
leadIn: 0,
entryOffset: { y: 0, z:-4},
exitOffset: { y: 0, z: 8 },
rampLength: 4.0,
conveyor: { enabled: false, yOffset: 0.6, zOffset: 0, width: 1.2 }
}
},
{
id: "furnace",
name: "炉火段",
deviceCode: "FUR1",
file: "/models/炉火段.glb",
position: { x: 0.0, y: 0.0, z: 2.2 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut: 2.0,
leadIn: 2.0,
entryOffset: { y: 0.25 },
exitOffset: { y: 0.25 },
rampLength: 3.5,
conveyor: { enabled: true, yOffset: 0.25, width: 1.2 }
}
},
// 锌锅/后处理
{
id: "pot",
name: "锌锅",
deviceCode: "POT",
file: "/models/锌锅.glb",
position: { x: 6.0, y: 0.0, z: 0.0 },
io: {
entryDir: "-x",
exitDir: "+z",
leadOut: 1.8,
leadIn: 2.2,
entryOffset: { y: 0.35 },
exitOffset: { y: 0.35, x: -0.6 },
rampLength: 3.0,
conveyor: { enabled: true, yOffset: 0.35, width: 1.2 }
}
},
{
id: "skinpass",
name: "光整机",
deviceCode: "TM",
file: "/models/光整机.glb",
position: { x: 6.0, y: 0.0, z: 18.0 },
rotation: { x: 0.0, y: Math.PI / 2, z: 0.0 },
io: {
entryDir: "-z",
exitDir: "+z",
leadOut: 2.2,
leadIn: 2.2,
entryOffset: { y: 0.25 },
exitOffset: { y: 0.25 },
rampLength: 3.5,
conveyor: { enabled: true, yOffset: 0.25, width: 1.2 }
}
},
{
id: "bridle",
name: "拉矫机",
deviceCode: "TL",
file: "/models/拉轿机.glb",
rotation: { x: 0.0, y: Math.PI / 2, z: 0.0 },
position: { x: 8.0, y: 0.0, z: 15.0 },
io: {
entryDir: "-z",
exitDir: "+x",
leadOut: 2.0,
leadIn: 2.0,
entryOffset: { y: 0.25 },
exitOffset: { y: 0.25 },
rampLength: 3.0,
conveyor: { enabled: true, yOffset: 0.25, width: 1.2 }
}
},
// 剪切/卷取
{
id: "shear",
name: "剪切机",
deviceCode: "EXC",
file: "/models/剪切机.glb",
position: { x: 16.0, y: 0.0, z: 15.0 },
rotation: { x: 0.0, y: Math.PI / 2, z: 0.0 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut: 2.0,
leadIn: 2.0,
entryOffset: { y: 0.3 },
exitOffset: { y: 0.3 },
rampLength: 3.0,
conveyor: { enabled: true, yOffset: 0.3, width: 1.2 }
}
},
{
id: "coiler",
name: "卷取机",
deviceCode: "TR",
file: "/models/卷取机.glb",
position: { x: 24.0, y: 0.0, z: 22.0 },
rotation: { x: 0.0, y: Math.PI / 2, z: 0.0 },
io: {
entryDir: "-x",
exitDir: "+x",
leadOut: 2.0,
leadIn: 2.0,
entryOffset: { y: 0.3 },
exitOffset: { y: 0.3 },
conveyor: { enabled: true, yOffset: 0.3, width: 1.2 }
}
},
]
}

View File

@@ -57,7 +57,7 @@ $theme-text-gray: #a1a6af;
// 仅维护组件自身样式,无定位属性
.current-time {
padding: 1.2vw 1.8vw;
background: white;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(8px);
border: 1px solid rgba(167, 172, 180, 0.3);
border-radius: 8px;

View File

@@ -1,13 +1,9 @@
<template>
<div class="industrial-dashboard">
<!-- Header: 系统标题和当前时间 -->
<div class="dashboard-header">
<div class="header-left">
<h1 class="system-title">生产监控大屏</h1>
<p class="system-subtitle">实时生产数据与设备状态</p>
</div>
<div class="header-right">
<CurrentTime />
<!-- 顶部产线三维展示时间显示放入3D组件内部 -->
<div class="production-line-section">
<div class="production-line-view">
<ProductionLine />
</div>
</div>
@@ -180,9 +176,9 @@
</template>
<script>
import CurrentTime from "./components/CurrentTime.vue";
import HomeMain from "./components/HomeMain.vue";
import MiniTable from "./components/MiniTable.vue";
import ProductionLine from "./l2/productLine/ProductLine.vue";
// 引入日志API / 生产相关API
import { getLogDataPage } from "@/api/l2/log";
import { getRollHistorytList } from '@/api/l2/roller'
@@ -192,7 +188,7 @@ import { getCurrentProducingPlan, getCurrentProcessParams } from "@/api/business
export default {
name: "Index",
components: { CurrentTime, HomeMain, MiniTable, TrackMeasure },
components: { HomeMain, MiniTable, TrackMeasure, ProductionLine },
data() {
return {
// KPI指标数据
@@ -431,30 +427,16 @@ $theme-text-gray: #c9cdcf;
min-height: calc(100vh - 60px);
}
/* 仪表板头部 */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
/* 顶部产线展示区 */
.production-line-section {
margin-bottom: 20px;
padding: 20px;
background: #ffffff; // 简洁白色背景,避免低级渐变色
.production-line-view {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.header-left {
.system-title {
color: #000;
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
}
.system-subtitle {
color: #000;
font-size: 14px;
margin: 0;
}
padding: 0;
overflow: hidden;
}
}

File diff suppressed because it is too large Load Diff

7
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
splitpanes:
specifier: 2.4.1
version: 2.4.1
three:
specifier: ^0.158.0
version: 0.158.0
vue:
specifier: 2.6.12
version: 2.6.12
@@ -14235,6 +14238,10 @@ packages:
webpack: 4.47.0
dev: true
/three@0.158.0:
resolution: {integrity: sha512-TALj4EOpdDPF1henk2Q+s17K61uEAAWQ7TJB68nr7FKxqwyDr3msOt5IWdbGm4TaWKjrtWS8DJJWe9JnvsWOhQ==}
dev: false
/three@0.180.0:
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
dev: false