Files
klp-mono/apps/hand-factory/components/lines/zinc1.vue
砂糖 166afcb959 feat: 更新生产线配置并优化WebSocket连接
- 移除未使用的生产线组件和配置
- 添加WebSocket URL配置项
- 将WebSocket连接改为使用uni.connectSocket
- 添加连接状态日志便于调试
2026-01-15 15:08:12 +08:00

806 lines
19 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>
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 1" @scroll="onScroll">
<!-- 顶部状态栏 -->
<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>
<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>
</view>
<view v-else class="placeholder-box">
<text class="placeholder-text">等待 {{ dev.desc }} 数据...滚动到此处加载</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { listDeviceEnumAll } from '@/api/pocket/deviceEnum'
import { getDeviceFieldMetaAll } from '@/api/pocket/deviceFieldMeta'
import { createMeasureSocket } from '@/utils/socketMeasure'
export default {
data() {
return {
currentTab: 1,
tabData: [
{ text: '实时监控', value: 1 }
],
isRefreshing: false,
isConnected: false,
lastUpdateTime: '—',
// socket最新消息
latestMeasure: null,
// 设备定义(后端 DeviceEnum 全量)
deviceDefs: [],
// 字段元数据fieldName -> {label, unit, description}
fieldMeta: {},
// device_field_trend: deviceCode -> { fieldName -> trendDTO }
fieldTrendMap: {},
// 懒加载:已订阅的设备列表
subscribedDeviceCodes: [],
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,
subscribedDeviceCodes: []
}
},
mounted() {
this.initSocket()
Promise.all([this.loadDeviceDefs(), this.loadFieldMeta()]).then(() => {
// 页面初始化完成后,默认订阅首屏设备(懒加载)
this.$nextTick(() => {
this.updateVisibleDeviceSubscriptions()
})
})
this.updateLastTime()
},
beforeDestroy() {
if (this.socketClient) this.socketClient.close()
if (this.fieldTrendSocket) this.fieldTrendSocket.close()
},
methods: {
async loadDeviceDefs() {
const res = await listDeviceEnumAll()
this.deviceDefs = (res && res.data) || []
},
async loadFieldMeta() {
const res = await getDeviceFieldMetaAll()
this.fieldMeta = (res && res.data) || {}
},
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 {
this.latestMeasure = JSON.parse(data)
this.updateLastTime()
} catch (e) {
// ignore
}
}
})
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()
},
// 滚动事件:节流触发订阅更新
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 || []
}
]
}
},
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 {}
}
},
getRealtimeFieldValue(dev, field) {
const msg = this.pickSourceMsg(dev)
const v = msg ? msg[field] : null
return this.formatValue(v)
},
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;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.scroll-container {
height: calc(100vh - 96rpx);
padding: 24rpx;
}
.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: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12rpx;
padding: 32rpx 24rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
}
.overview-card:nth-child(2) {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
box-shadow: 0 4rpx 12rpx rgba(245, 87, 108, 0.3);
}
.overview-card:nth-child(3) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
box-shadow: 0 4rpx 12rpx rgba(79, 172, 254, 0.3);
}
.overview-label {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 16rpx;
}
.overview-value {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.overview-unit {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 8rpx;
}
.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>