跳到主要内容

移动端 HMI

移动端 HMI 是现场工程师用手机/平板通过 WebHMI 访问设备的专用界面, 场景包括设备巡检、应急响应、远程启停、报警响应等。

本章讲的是现场设备移动监控, 不是企业移动办公/审批/CRM/OA。所有场景都聚焦设备状态与操作, 不涉及业务数据输入。

典型移动场景

场景动作所需 API
白班巡检走到每台设备前扫一眼屏幕页面浏览 + 状态订阅
夜间值班报警推送到手机 → 远程判断Notification + 变量订阅
远程启停远程停机或复位darra.write (白名单)
交接班手机查看设备状态与当班报警状态订阅 + 报警列表
现场应急收到严重报警 -> 开车赶到 -> 到达前通过手机先远程停机急停写入 + 通知

PWA: 可安装的 HMI App

manifest.json

{
"name": "Darra PLC HMI",
"short_name": "DarraPLC",
"start_url": "/web/mobile/",
"display": "standalone",
"background_color": "#111",
"theme_color": "#2a85ff",
"icons": [
{ "src": "/static/img/pwa-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/img/pwa-512.png", "sizes": "512x512", "type": "image/png" }
]
}

index.html 引用

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#2a85ff">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/static/img/pwa-192.png">
<title>Darra HMI</title>
</head>
<body>
<div id="app">...</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
</body>
</html>

iOS Safari "添加到主屏幕", Android Chrome "添加到主屏幕" 后即以 App 形式运行, 隐藏浏览器地址栏。

Service Worker 离线缓存

sw.js

const CACHE_NAME = 'darra-hmi-v1';
const STATIC_ASSETS = [
'/web/mobile/',
'/static/js/darra-plc.js',
'/static/js/darra-controls.js',
'/static/css/mobile.css',
'/static/img/pwa-192.png'
];

self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE_NAME).then(c => c.addAll(STATIC_ASSETS))
);
});

self.addEventListener('fetch', e => {
const url = new URL(e.request.url);

// WebSocket 和 API 不缓存
if (url.pathname.startsWith('/ws') || url.pathname.startsWith('/api/')) return;

// 静态资源优先缓存
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request))
);
});

self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
});

设备状态最后快照

离线进入 HMI 时至少能看到"最后 1 小时"的状态:

// 后台持续存 IndexedDB
darra.on('variable', ({ name, value, ts }) => {
saveToIdb(name, value, ts);
});

// 离线时加载
darra.on('offline', async () => {
const snapshot = await loadLatestSnapshotFromIdb();
renderFromSnapshot(snapshot);
showBanner('离线模式, 显示最后 ' + formatAgo(snapshot.ts));
});

darra.on('ready', () => hideBanner());

布局: 手机优先

单列堆叠

.mobile-layout {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 12px;
padding: 12px;
min-height: 100vh;
}

.mobile-card {
background: #1e1e1e;
border-radius: 12px;
padding: 16px;
color: #fff;
}

按钮尺寸 (手指触摸)

.mobile-btn {
min-height: 56px; /* ≥ 44pt, iOS HIG 推荐 */
min-width: 56px;
font-size: 18px;
border-radius: 10px;
/* 避免 tap 延迟 */
touch-action: manipulation;
}

大字号状态

<div class="status-big">
<darra-numeric var="MW.Yield" unit="" digits="0" size="54pt"></darra-numeric>
<darra-numeric var="MD.OEE" unit="%" digits="1" size="32pt"></darra-numeric>
</div>

手势操作

滑动切换页面

let startX = 0;
document.addEventListener('touchstart', e => startX = e.touches[0].clientX);
document.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - startX;
if (dx < -50) nextPage();
else if (dx > 50) prevPage();
});

下拉刷新

let pullStart = 0;
document.addEventListener('touchstart', e => pullStart = e.touches[0].clientY);
document.addEventListener('touchmove', e => {
const dy = e.touches[0].clientY - pullStart;
if (window.scrollY === 0 && dy > 80) showPullIndicator(dy);
});
document.addEventListener('touchend', e => {
const dy = e.changedTouches[0].clientY - pullStart;
if (window.scrollY === 0 && dy > 120) reloadSnapshot();
hidePullIndicator();
});

巡检流程

巡检员每天按既定路线走到每台设备前, 扫一眼手机确认状态:

<div class="patrol-flow" x-data="patrolFlow()">
<h2 x-text="`巡检进度 ${current}/${total}`"></h2>

<div class="equipment">
<h3 x-text="currentEquipment.name"></h3>

<darra-led :var="`${currentEquipment.id}.M.Running`"></darra-led>
<darra-numeric :var="`${currentEquipment.id}.MW.Yield`" unit=""></darra-numeric>
<darra-numeric :var="`${currentEquipment.id}.MD.OEE`" unit="%"></darra-numeric>
<darra-alarm-list :filter="`source=${currentEquipment.id}`" max-rows="3"></darra-alarm-list>
</div>

<div class="patrol-actions">
<button @click="markOK()">正常</button>
<button @click="markAbnormal()">异常</button>
<button @click="next()">下一台</button>
</div>
</div>

巡检记录写入 Historian:

async function markOK() {
await darra.writeGroup({
[`${currentEquipment.id}.DB_Patrol.LastOK`]: Date.now(),
[`${currentEquipment.id}.DB_Patrol.LastOperator`]: currentUser
});
next();
}

应急响应

报警 -> 响应

// Service Worker 接收 Push
self.addEventListener('push', e => {
const data = e.data.json();
e.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/static/img/alarm.png',
tag: data.alarmId,
vibrate: [200, 100, 200, 100, 200], // 震动
actions: [
{ action: 'ack', title: '确认' },
{ action: 'stop', title: '远程停机' },
{ action: 'view', title: '查看' }
]
})
);
});

self.addEventListener('notificationclick', e => {
e.notification.close();
const alarmId = e.notification.tag;
switch (e.action) {
case 'ack':
e.waitUntil(fetch(`/api/alarm/ack/${alarmId}`, { method: 'POST' }));
break;
case 'stop':
e.waitUntil(clients.openWindow('/web/mobile/emergency-stop?alarm=' + alarmId));
break;
default:
e.waitUntil(clients.openWindow('/web/mobile/alarms'));
}
});

远程紧急停机页

<div class="emergency">
<h1 style="color:#c00">紧急停机</h1>
<p>设备: <span x-text="alarm.source"></span></p>
<p>原因: <span x-text="alarm.message"></span></p>

<darra-button var="M.EStopRemote"
mode="toggle"
hold-to-confirm="3000"
style="width:100%;height:200px;font-size:48px;background:#c00;color:white"
label="长按 3 秒停机">
</darra-button>

<button @click="location.href='/web/mobile'">取消</button>
</div>

长按 3 秒防手抖误触。

移动网络优化

  • 首屏只订阅关键 10 个变量, 延后订阅其他
  • WebSocket 断线 3 秒后重连, 用指数退避 (3/6/12/30)
  • 图片懒加载 (<img loading="lazy">)
  • 禁用不必要的 CSS 动画 (@media (prefers-reduced-motion))
  • WiFi 与蜂窝不同策略 (navigator.connection.effectiveType)
if (navigator.connection && navigator.connection.effectiveType === '3g') {
darra.init({ updateRate: 1000 }); // 3G 降到 1 秒
} else {
darra.init({ updateRate: 200 }); // 4G/5G/WiFi 200ms
}

相关文档