移动端 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
}