鸿蒙PC平台逻控 PLC 编辑器适配实战:基于 Electron 壳方案的纯 JavaScript 梯形图编程与仿真引擎开发
逻控 PLC 编辑器是一款纯 JavaScript 实现的梯形图(Ladder Diagram)编程环境,支持七种 PLC 元素(常开触点、常闭触点、线圈、TON/TOF 定时器、CTU/CTD 计数器)、多程序管理、变量面板、仿真运行与日志查看、项目序列化与文件读写。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备梯形图编辑、Canvas 2D 渲染、拖拽放置、仿真执行与 .plc 项目文件管理的完整过程——涵盖梯形图数据模型、Canvas 渲染引擎、简化执行仿真、变量管理系统、鸿蒙 IPC 文件通道、自定义对话框与 Catppuccin Mocha 深色主题等核心功能的实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_OpenPLC
一、技术架构分析
1.1 整体架构
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:
- Electron Web 层:运行于 ArkWeb 引擎,提供完整的梯形图编辑、Canvas 2D 渲染、仿真执行、变量管理与项目文件读写能力
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力
这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。梯形图引擎为纯 JavaScript 实现,不依赖任何第三方库,所有图形通过 Canvas 2D API 实时绘制,仿真逻辑完整覆盖 TON/TOF 定时器与 CTU/CTD 计数器的时序行为。
1.2 技术方案
| 对比项 | 传统 PLC 编程软件(如 OpenPLC Editor) | 鸿蒙适配版(逻控 PLC 编辑器) |
|---|---|---|
| 编程语言 | IEC 61131-3 标准(LD/FBD/ST 等) | 梯形图 LD(纯 JavaScript 实现) |
| 图形渲染 | 原生 GUI 框架(wxWidgets/Qt) | Canvas 2D API 实时绘制 |
| 仿真执行 | 软 PLC 运行时 | 简化 JavaScript 逐梯级扫描引擎 |
| 开发语言 | C++ / Python | HTML5/Canvas/Vanilla JavaScript |
| 文件操作 | 本地文件 I/O | Node.js fs 模块 + IPC |
| 部署方式 | 安装包 | HAP 包(鸿蒙应用) |
| 运行环境 | Windows/macOS/Linux | 鸿蒙 PC(ArkWeb 引擎) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| 梯形图引擎 | 七种元素类型(常开/常闭触点、线圈、TON/TOF 定时器、CTU/CTD 计数器) |
| Canvas 渲染 | 左右母线、梯级编号、导线分段绘制、元素符号、选中高亮 |
| 多程序管理 | 程序树列表、新建/切换程序、梯级增删 |
| 变量系统 | 输入 %IX / 输出 %QX / 内部位 %MX / 定时器 %T / 计数器 %C 五类管理 |
| 仿真执行 | 逐梯级扫描、逻辑状态传递、定时器时序、计数器边沿检测 |
| 元素交互 | 点击放置、拖拽移动、Delete 键删除、命中测试 |
| 文件读写 | .plc 项目打开/保存,JSON 序列化 + IPC 通道 |
| 示例程序 | 电机启动/停止控制(含常开/常闭触点、定时器、线圈联动) |
| 鸿蒙稳定 | 自定义关于弹窗 + 三防窗口配置 + 禁用原生菜单 + 状态栏反馈 |
二、环境准备
2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/Canvas 2D/Vanilla JavaScript |
| 渲染引擎 | 纯 JavaScript 梯形图引擎 |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron/ # Electron 壳
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/ # 部署目录(业务代码平铺)
│ ├── main.js # 主进程(窗口管理 + 文件操作 IPC)
│ ├── renderer.js # 渲染进程(UI 交互 + Canvas 渲染调度 + 仿真)
│ ├── index.html # 页面布局(工具栏 + 程序树 + 画布 + 变量面板 + 状态栏)
│ ├── plc-engine.js # 梯形图引擎(数据模型 + Canvas 渲染 + 执行引擎 + 序列化)
│ ├── package.json # 项目配置
│ └── styles/
│ └── openplc.css # Catppuccin Mocha 深色主题样式
└── AppScope/ # 应用配置(bundleName: com.wei.luokongplc)
2.3 依赖说明
本项目零第三方库依赖,所有功能均由纯 JavaScript 手写实现:
| 模块 | 用途 |
|---|---|
| plc-engine.js | 梯形图引擎:数据模型、Canvas 2D 渲染、仿真执行、序列化 |
| renderer.js | 渲染进程:UI 交互、文件操作、Canvas 调度、变量管理 |
| Canvas 2D API | 梯形图图形实时绘制(母线、导线、触点、线圈、定时器、计数器) |
| Node.js fs | 主进程同步读写 .plc 项目文件 |
三、核心适配流程
3.1 主进程实现(main.js)
主进程负责窗口创建和文件操作 IPC。鸿蒙平台需要特殊的稳定性配置。
// 逻控 PLC 编辑器 主进程
// 职责:窗口管理 + 文件操作 IPC(打开/保存 PLC 项目 JSON)
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var Menu = electron.Menu;
var fs = require('fs');
var path = require('path');
app.disableHardwareAcceleration();
// 移除应用级默认菜单栏(File/Edit/View/Window/Help)
Menu.setApplicationMenu(null);
var mainWindow = null;
function createWindow() {
try {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.95),
height: Math.floor(display.workAreaSize.height * 0.90),
frame: true,
transparent: false,
resizable: true,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
// 窗口级菜单移除(与应用级双保险)
mainWindow.setMenu(null);
} catch (e) {
console.warn('[逻控] 创建窗口失败:', e.message);
}
}
app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });
关键点说明:
- disableHardwareAcceleration——鸿蒙 ArkWeb 引擎下 GPU 硬件加速可能导致白屏或渲染异常,必须在 app ready 之前调用禁用
- Menu.setApplicationMenu(null) + mainWindow.setMenu(null) 双保险——彻底移除 Electron 默认菜单栏,避免鸿蒙下菜单渲染引发崩溃
- frame: true / transparent: false / resizable: true 三防配置——保留系统原生标题栏、禁止透明窗口、允许用户调整尺寸,这三项组合是鸿蒙上窗口稳定运行的基线
- try-catch 包裹 createWindow——鸿蒙下 SubWindow 创建可能超时,未捕获异常会导致主进程直接崩溃
- 窗口尺寸按主显示区域 95% x 90% 计算——适配鸿蒙 PC 不同分辨率
3.2 文件操作 IPC 通道
主进程注册两个 IPC 通道,分别处理 .plc 项目的打开和保存:
// ========== 文件操作 IPC ==========
// 打开 PLC 项目 JSON
ipcMain.handle('file:open', function() {
return dialog.showOpenDialog(mainWindow, {
title: '打开 PLC 项目',
filters: [{ name: 'PLC 项目', extensions: ['plc'] }, { name: 'JSON 文件', extensions: ['json'] }],
properties: ['openFile']
}).then(function(result) {
if (result.canceled || result.filePaths.length === 0) {
return { success: false, message: '取消打开' };
}
var filePath = result.filePaths[0];
try {
var content = fs.readFileSync(filePath, 'utf-8');
return { success: true, filePath: filePath, content: content };
} catch (e) {
return { success: false, message: '读取失败: ' + e.message };
}
});
});
// 保存 PLC 项目 JSON
ipcMain.handle('file:save', function(event, content) {
return dialog.showSaveDialog(mainWindow, {
title: '保存 PLC 项目',
defaultPath: 'PLC项目.plc',
filters: [{ name: 'PLC 项目', extensions: ['plc'] }, { name: 'JSON 文件', extensions: ['json'] }]
}).then(function(result) {
if (result.canceled || !result.filePath) {
return { success: false, message: '取消保存' };
}
try {
fs.writeFileSync(result.filePath, content, 'utf-8');
return { success: true, filePath: result.filePath };
} catch (e) {
return { success: false, message: '保存失败: ' + e.message };
}
});
});
设计要点:
- 对话框走 IPC、文件读写也在主进程——与 ApE 的渲染进程 fs 直写方案不同,逻控 PLC 编辑器的文件操作频率较低(仅打开/保存项目时触发),不存在高频连续读写场景,因此保留标准 IPC 通道模式
- .plc 文件本质是 JSON 格式——序列化由引擎层 serialize() 完成,反序列化由 deserialize() 完成,主进程只负责透传字符串内容
- 文件过滤器同时支持 .plc 和 .json 两种扩展名——方便用户直接打开 JSON 格式的备份文件
3.3 页面布局设计(index.html)
index.html 采用四段式结构:顶部工具栏 + 主体三栏(程序树 / Canvas 画布 / 变量面板)+ 底部状态栏 + 对话框层:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>逻控 PLC 编辑器</title>
<link rel="stylesheet" href="styles/openplc.css">
</head>
<body>
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-brand">逻控 PLC 编辑器</div>
<div class="toolbar-separator"></div>
<button class="tool-btn" id="btnNew">📄 新建</button>
<button class="tool-btn" id="btnOpen">📂 打开</button>
<button class="tool-btn" id="btnSave">💾 保存</button>
<div class="toolbar-separator"></div>
<button class="tool-btn" id="btnAddProgram">➕ 程序</button>
<button class="tool-btn" id="btnAddRung">📊 梯级</button>
<button class="tool-btn" id="btnExample">📋 示例</button>
<div class="toolbar-separator"></div>
<button class="elem-btn" data-type="contact_no">|--|</button>
<button class="elem-btn" data-type="contact_nc">|--/|--</button>
<button class="elem-btn" data-type="coil">--( )--</button>
<button class="elem-btn" data-type="timer_ton">TON</button>
<button class="elem-btn" data-type="timer_tof">TOF</button>
<button class="elem-btn" data-type="counter_ctu">CTU</button>
<button class="elem-btn" data-type="counter_ctd">CTD</button>
<div class="toolbar-separator"></div>
<button class="tool-btn tool-primary" id="btnRun">▶ 运行</button>
</div>
<!-- 主体 -->
<div class="main">
<!-- 左侧程序树 -->
<div class="sidebar" id="sidebar">
<div class="sidebar-title">程序列表</div>
<div class="program-list" id="programList"></div>
</div>
<!-- 中央梯形图画布 -->
<div class="canvas-container" id="canvasContainer">
<canvas id="ladderCanvas"></canvas>
</div>
<!-- 右侧变量/日志面板 -->
<div class="properties" id="properties">
<div class="tabbar">
<button class="tab-btn active" data-tab="vars">变量</button>
<button class="tab-btn" data-tab="log">日志</button>
</div>
<div class="tab-panel active" id="panel-vars">
<button class="var-add-btn" id="btnAddVar">+ 添加变量</button>
<div class="var-section">
<div class="var-title">输入 %I</div>
<div class="var-list" id="varInputs"></div>
</div>
<div class="var-section">
<div class="var-title">输出 %Q</div>
<div class="var-list" id="varOutputs"></div>
</div>
<div class="var-section">
<div class="var-title">内部位 %M</div>
<div class="var-list" id="varMarkers"></div>
</div>
<div class="var-section">
<div class="var-title">定时器 %T</div>
<div class="var-list" id="varTimers"></div>
</div>
<div class="var-section">
<div class="var-title">计数器 %C</div>
<div class="var-list" id="varCounters"></div>
</div>
</div>
<div class="tab-panel hidden" id="panel-log">
<div class="log-view" id="logView">点击「运行」查看执行日志</div>
</div>
</div>
</div>
<!-- 底部状态栏 -->
<div class="statusbar">
<span class="status-item" id="statusInfo">就绪</span>
<div class="status-spacer"></div>
<span class="status-item" id="statusProgram">-</span>
<span class="status-item status-link" id="btnAbout">关于</span>
</div>
<script src="plc-engine.js"></script>
<script src="renderer.js"></script>
<!-- 变量编辑对话框(grid 同格叠加) -->
<div class="dialog-layer" id="varDialogLayer">
<div class="dialog-box">
<div class="dialog-title">添加变量</div>
<div class="dialog-body">
<div class="form-row">
<label>变量类型:</label>
<div class="seg-group" id="varTypeGroup">
<button class="seg-btn active" data-value="inputs">输入 %IX</button>
<button class="seg-btn" data-value="outputs">输出 %QX</button>
<button class="seg-btn" data-value="markers">内部位 %MX</button>
</div>
</div>
<div class="form-row">
<label>变量名称:</label>
<input type="text" id="varName" placeholder="如:0.0(将生成 %IX0.0)">
</div>
<div class="form-row">
<label>初始值:</label>
<div class="seg-group" id="varValueGroup">
<button class="seg-btn active" data-value="false">FALSE</button>
<button class="seg-btn" data-value="true">TRUE</button>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="dialog-btn" id="btnVarCancel">取消</button>
<button class="dialog-btn dialog-btn-primary" id="btnVarOk">确定</button>
</div>
</div>
</div>
<!-- 关于对话框(开源信息) -->
<div class="dialog-layer" id="aboutDialogLayer">
<div class="dialog-box">
<div class="dialog-title">关于 逻控 PLC 编辑器</div>
<div class="dialog-body about-body">
<h3>开源声明</h3>
<p>本应用是运行于鸿蒙 PC 的 Electron 壳应用(Electron 基于 MIT 协议开源),提供梯形图(LD)编程与仿真运行环境。梯形图引擎、Canvas 2D 渲染与项目序列化均使用 JavaScript 独立实现,无第三方运行时依赖;界面设计参考 OpenPLC 等开源 PLC 项目的通用交互习惯,全部代码独立编写,不包含其任何源代码,在此谨向原作者与开源社区致谢。</p>
<h3>免责声明</h3>
<p>本应用提供的仿真结果仅用于学习与验证梯形图逻辑,不可直接用于实际工业控制场合;使用者应自行承担因误用产生的风险与后果。</p>
<h3>完全开源</h3>
<p>本项目代码完全开源,遵循 Apache-2.0 开源许可证。欢迎社区贡献代码、报告问题或提出功能建议。</p>
</div>
<div class="dialog-footer">
<button class="dialog-btn dialog-btn-primary" id="btnAboutClose">确定</button>
</div>
</div>
</div>
</body>
</html>
布局要点:
- CSS Grid 三行布局(toolbar / main / statusbar),对话框层通过 grid-area 叠加在整层之上
- 主体区域三栏布局:左侧程序树(180px 固定宽)+ 中央 Canvas 画布(flex 弹性填充)+ 右侧变量面板(240px 固定宽)
- 变量/日志双标签切换——右侧面板通过 tab-panel 的 hidden 类切换显示
- 分段按钮组替代原生 select——规避鸿蒙适配层弹层崩溃问题
- 两个 dialog-layer 分别用于变量编辑和关于信息,通过 open 类控制显隐
3.4 梯形图引擎核心(plc-engine.js)
plc-engine.js 是整个应用的核心,负责数据模型管理、Canvas 图形渲染、仿真执行与项目序列化。
3.4.1 元素类型与数据模型
// ========== 梯形图元素类型 ==========
var ELEMENT_TYPES = {
CONTACT_NO: 'contact_no', // 常开触点 --| |--
CONTACT_NC: 'contact_nc', // 常闭触点 --|/|--
COIL: 'coil', // 线圈 --( )--
TIMER_TON: 'timer_ton', // 接通延时定时器
TIMER_TOF: 'timer_tof', // 断开延时定时器
COUNTER_CTU: 'counter_ctu', // 加计数器
COUNTER_CTD: 'counter_ctd' // 减计数器
};
// ========== 数据模型 ==========
function createPlcProject() {
return {
programs: [],
variables: {
inputs: {}, // %IX0.0, %IX0.1, ...
outputs: {}, // %QX0.0, %QX0.1, ...
markers: {}, // %MX0.0, %MX0.1, ... (内部位)
timers: {}, // %T0, %T1, ...
counters: {} // %C0, %C1, ...
},
nextId: 1
};
}
数据模型设计遵循 IEC 61131-3 标准的变量地址命名规范:
- 输入位 %IX0.0~0.7——对应物理输入端口
- 输出位 %QX0.0~0.7——对应物理输出端口
- 内部位 %MX0.0~——辅助继电器,不映射物理端口
- 定时器 %T0~、计数器 %C0~——带预设值(preset)和经过值(elapsed/current)的时序元件
程序(Program)包含多个梯级(Rung),每个梯级包含多个元素(Element),元素按 x 坐标从左到右排列。
3.4.2 Canvas 渲染管线
// ========== Canvas 渲染 ==========
function renderLadder(ctx, project, progId, options) {
var opt = options || {};
var prog = findProgram(project, progId);
if (!prog) return;
var rungHeight = 100;
var startY = 30;
// 背景
ctx.fillStyle = opt.bgColor || '#1e1e2e';
ctx.fillRect(0, 0, opt.width || 800, opt.height || 600);
// 绘制每个梯级
for (var i = 0; i < prog.rungs.length; i++) {
var rung = prog.rungs[i];
var y = startY + i * rungHeight;
drawRung(ctx, rung, y, opt);
}
}
function drawRung(ctx, rung, y, opt) {
var startX = 40;
var endX = (opt.width || 800) - 40;
var midY = y + 50;
// 左侧母线
ctx.strokeStyle = opt.railColor || '#89b4fa';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(startX, y + 80);
ctx.stroke();
// 右侧母线
ctx.beginPath();
ctx.moveTo(endX, y);
ctx.lineTo(endX, y + 80);
ctx.stroke();
// 梯级编号
ctx.fillStyle = opt.labelColor || '#a6adc8';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText('梯级 ' + rung.number, 5, y + 15);
// 导线(分段绘制:不穿过触点两竖线之间与功能块内部)
if (rung.elements.length > 0) {
ctx.strokeStyle = opt.wireColor || '#cdd6f4';
ctx.lineWidth = 1.5;
ctx.beginPath();
var sorted = rung.elements.slice().sort(function(a, b) { return a.x - b.x; });
var cursor = startX;
for (var k = 0; k < sorted.length; k++) {
var sx = startX + 60 + sorted[k].x;
if (sx > cursor) {
ctx.moveTo(cursor, midY);
ctx.lineTo(sx, midY);
}
cursor = Math.max(cursor, sx + elemVisualWidth(sorted[k]));
}
if (endX > cursor) {
ctx.moveTo(cursor, midY);
ctx.lineTo(endX, midY);
}
ctx.stroke();
}
// 绘制元素(覆盖在导线之上)
for (var i = 0; i < rung.elements.length; i++) {
var elem = rung.elements[i];
var x = startX + 60 + elem.x;
drawElement(ctx, elem, x, midY, opt);
}
}
渲染管线采用分层绘制策略:先画母线与导线,再画元素符号,确保元素覆盖在导线之上。导线绘制时按元素 x 坐标排序,分段绘制——跳过触点两竖线之间的区域和功能块内部区域,呈现真实的梯形图电气连接效果。
3.4.3 元素图形绘制
function drawContact(ctx, x, y, name, negated, opt) {
// 两条竖线
ctx.strokeStyle = opt.contactColor || '#a6e3a1';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y - 15);
ctx.lineTo(x, y + 15);
ctx.moveTo(x + 20, y - 15);
ctx.lineTo(x + 20, y + 15);
ctx.stroke();
// 常闭加斜线
if (negated) {
ctx.beginPath();
ctx.moveTo(x, y + 15);
ctx.lineTo(x + 20, y - 15);
ctx.stroke();
}
// 标签
ctx.fillStyle = opt.labelColor || '#cdd6f4';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText(name, x + 10, y - 20);
}
function drawCoil(ctx, x, y, name, opt) {
// 括号形状
ctx.strokeStyle = opt.coilColor || '#fab387';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x + 5, y, 15, -Math.PI / 2, Math.PI / 2);
ctx.arc(x + 15, y, 15, Math.PI / 2, -Math.PI / 2, true);
ctx.stroke();
// 标签
ctx.fillStyle = opt.labelColor || '#cdd6f4';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText(name, x + 10, y - 20);
}
function drawTimer(ctx, x, y, elem, opt) {
// 矩形框
ctx.strokeStyle = opt.timerColor || '#cba6f7';
ctx.lineWidth = 2;
ctx.strokeRect(x, y - 20, 60, 40);
// 名称(框上方)
ctx.fillStyle = opt.labelColor || '#cdd6f4';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText(elem.name, x + 30, y - 26);
// 类型标签
ctx.fillStyle = opt.timerColor || '#cba6f7';
ctx.font = 'bold 10px monospace';
ctx.fillText(elem.type === ELEMENT_TYPES.TIMER_TON ? 'TON' : 'TOF', x + 30, y - 7);
// 预设值/经过值
ctx.fillStyle = opt.labelColor || '#cdd6f4';
ctx.font = '9px monospace';
ctx.fillText('PT:' + elem.preset, x + 30, y + 6);
ctx.fillText('ET:' + (elem.elapsed || 0), x + 30, y + 17);
}
function drawCounter(ctx, x, y, elem, opt) {
// 矩形框
ctx.strokeStyle = opt.counterColor || '#f9e2af';
ctx.lineWidth = 2;
ctx.strokeRect(x, y - 20, 60, 40);
// 名称(框上方)
ctx.fillStyle = opt.labelColor || '#cdd6f4';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText(elem.name, x + 30, y - 26);
// 类型标签
ctx.fillStyle = opt.counterColor || '#f9e2af';
ctx.font = 'bold 10px monospace';
ctx.fillText(elem.type === ELEMENT_TYPES.COUNTER_CTU ? 'CTU' : 'CTD', x + 30, y - 7);
// 预设值/当前值
ctx.fillStyle = opt.labelColor || '#cdd6f4';
ctx.font = '9px monospace';
ctx.fillText('PV:' + elem.preset, x + 30, y + 6);
ctx.fillText('CV:' + (elem.current || 0), x + 30, y + 17);
}
四种元素的图形规范:
- 常开触点:两条竖线(20px 宽),绿色 #a6e3a1
- 常闭触点:两条竖线 + 一条斜线,绿色 #a6e3a1
- 线圈:左右两个半圆弧(括号形状),橙色 #fab387
- 定时器/计数器:60x40 矩形框,紫色 #cba6f7 / 黄色 #f9e2af,框内显示类型标签和预设值/经过值
3.4.4 仿真执行引擎
// ========== 简化执行引擎 ==========
function executeProgram(project, progId) {
var prog = findProgram(project, progId);
if (!prog) return { success: false, error: '程序不存在' };
var log = [];
// 扫描每个梯级
for (var i = 0; i < prog.rungs.length; i++) {
var rung = prog.rungs[i];
var rungLog = executeRung(project, rung);
log.push(rungLog);
}
return { success: true, log: log };
}
function executeRung(project, rung) {
var result = { rung: rung.number, elements: [] };
var logicState = true; // 从左母线开始为 true
for (var i = 0; i < rung.elements.length; i++) {
var elem = rung.elements[i];
var elemLog = { type: elem.type, name: elem.name };
if (elem.type === ELEMENT_TYPES.CONTACT_NO) {
var val = getVariable(project, elem.name);
logicState = logicState && val;
elemLog.state = val;
} else if (elem.type === ELEMENT_TYPES.CONTACT_NC) {
var val = getVariable(project, elem.name);
logicState = logicState && !val;
elemLog.state = !val;
} else if (elem.type === ELEMENT_TYPES.COIL) {
setVariable(project, elem.name, logicState);
elemLog.state = logicState;
} else if (elem.type === ELEMENT_TYPES.TIMER_TON || elem.type === ELEMENT_TYPES.TIMER_TOF) {
// 真实定时器:基于时间戳
var now = Date.now();
if (!elem.startTime) elem.startTime = now;
if (elem.type === ELEMENT_TYPES.TIMER_TON) {
// TON:输入为 TRUE 时开始计时,达到预设值后 Q 输出 TRUE
if (logicState) {
elem.elapsed = now - elem.startTime;
var done = elem.elapsed >= elem.preset;
setVariable(project, elem.name + '.Q', done);
setVariable(project, elem.name + '.ET', elem.elapsed);
elemLog.state = done;
logicState = done;
} else {
// 输入为 FALSE,复位
elem.startTime = now;
elem.elapsed = 0;
setVariable(project, elem.name + '.Q', false);
setVariable(project, elem.name + '.ET', 0);
elemLog.state = false;
logicState = false;
}
} else { // TOF
// TOF:输入 TRUE 时 Q=TRUE;输入转 FALSE 后维持输出至预设时间
if (logicState) {
elem.startTime = now;
elem.elapsed = 0;
setVariable(project, elem.name + '.Q', true);
setVariable(project, elem.name + '.ET', 0);
elemLog.state = true;
logicState = true;
} else {
elem.elapsed = now - elem.startTime;
var done = elem.elapsed >= elem.preset;
var q = !done;
setVariable(project, elem.name + '.Q', q);
setVariable(project, elem.name + '.ET', elem.elapsed);
elemLog.state = q;
logicState = q;
}
}
} else if (elem.type === ELEMENT_TYPES.COUNTER_CTU || elem.type === ELEMENT_TYPES.COUNTER_CTD) {
// 真实计数器:检测输入上升沿
var inputState = logicState;
var prevInput = elem._prevInput || false;
if (elem.type === ELEMENT_TYPES.COUNTER_CTU) {
// CTU:输入上升沿则加计数
if (inputState && !prevInput) {
elem.current = (elem.current || 0) + 1;
}
var done = elem.current >= elem.preset;
setVariable(project, elem.name + '.Q', done);
setVariable(project, elem.name + '.CV', elem.current);
elemLog.state = done;
logicState = done;
} else { // CTD
// CTD:输入上升沿则减计数,减到 0 时 Q=TRUE
if (inputState && !prevInput) {
elem.current = Math.max(0, (elem.current || 0) - 1);
}
var done = elem.current <= 0;
setVariable(project, elem.name + '.Q', done);
setVariable(project, elem.name + '.CV', elem.current);
elemLog.state = done;
logicState = done;
}
elem._prevInput = inputState;
}
result.elements.push(elemLog);
}
return result;
}
仿真引擎的核心逻辑:
- 逐梯级扫描——每个梯级从左母线(logicState = true)开始,依次经过各元素
- 触点逻辑——常开触点直接传递变量值(AND 运算),常闭触点取反后传递
- 线圈输出——将当前逻辑状态写入变量表
- TON 定时器——输入 TRUE 时开始计时,达到预设值(PT)后 Q 输出 TRUE;输入 FALSE 时复位
- TOF 定时器——输入 TRUE 时 Q 立即为 TRUE;输入转 FALSE 后维持 Q=TRUE 直到经过时间达到预设值
- CTU 计数器——检测输入上升沿(FALSE→TRUE),每次上升沿加 1,达到预设值后 Q=TRUE
- CTD 计数器——检测输入上升沿,每次上升沿减 1,减到 0 后 Q=TRUE
3.4.5 序列化与反序列化
// ========== 序列化 ==========
function serialize(project) {
return JSON.stringify(project, null, 2);
}
function deserialize(json) {
var data = JSON.parse(json);
// 确保 nextId 大于所有现有 ID
var maxId = 0;
function scanObj(obj) {
if (obj && typeof obj === 'object') {
if (obj.id && typeof obj.id === 'string' && obj.id.indexOf('plc_') === 0) {
var num = parseInt(obj.id.replace('plc_', ''), 10);
if (num > maxId) maxId = num;
}
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
scanObj(obj[keys[i]]);
}
}
}
scanObj(data);
data.nextId = maxId + 1;
return data;
}
序列化采用 JSON 格式,文件扩展名为 .plc。反序列化时递归扫描整个对象树,找出所有 plc_ 前缀 ID 中的最大数值,确保 nextId 严格递增,避免打开已保存项目后产生 ID 冲突。
3.5 渲染进程交互(renderer.js)
渲染进程负责 UI 事件绑定、Canvas 渲染调度、元素交互与文件操作协调。
3.5.1 状态管理与初始化
var state = {
project: null,
currentProgId: null,
selectedElemType: null, // 待放置的元素类型
selectedElemId: null, // 当前选中的元素 ID
isDragging: false, // 是否正在拖拽
dragOffset: { x: 0, y: 0 },
dragRungId: null, // 拖拽时所在的梯级 ID
suppressClickPlace: false, // 本次点击命中已有元素时,抑制放置
executionMode: 'single', // single | continuous
executionTimer: null
};
// ========== 初始化 ==========
document.addEventListener('DOMContentLoaded', function() {
el = {
canvas: document.getElementById('ladderCanvas'),
canvasContainer: document.getElementById('canvasContainer'),
programList: document.getElementById('programList'),
varInputs: document.getElementById('varInputs'),
varOutputs: document.getElementById('varOutputs'),
varMarkers: document.getElementById('varMarkers'),
varTimers: document.getElementById('varTimers'),
varCounters: document.getElementById('varCounters'),
logView: document.getElementById('logView'),
statusInfo: document.getElementById('statusInfo'),
statusProgram: document.getElementById('statusProgram')
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// 初始化空项目
newProject();
// 事件绑定
bindToolbarEvents();
bindCanvasEvents();
bindKeyboardEvents();
bindTabEvents();
bindVarDialogEvents();
bindAboutEvents();
// 启动渲染循环
requestAnimationFrame(renderLoop);
});
状态机设计要点:
- selectedElemType 与 selectedElemId 分离——前者记录工具栏选中的待放置元素类型,后者记录画布上已选中的元素 ID
- suppressClickPlace 标志——当本次 mousedown 命中了已有元素(进入拖拽流程),click 事件不再触发放置逻辑,避免拖拽结束后误放一个元素
- dragRungId 在 mousedown 时记录——拖拽过程中鼠标可能移出原梯级区域,必须锁定拖拽起始梯级
3.5.2 Canvas 交互事件
// ========== Canvas 事件 ==========
function bindCanvasEvents() {
// 辅助函数:根据 mouseY 计算梯级索引
function getRungIndex(mouseY) {
var rungHeight = 100;
var startY = 30;
var idx = Math.floor((mouseY - startY) / rungHeight);
return Math.max(0, idx);
}
// 点击选中元素
el.canvas.addEventListener('mousedown', function(e) {
if (!state.currentProgId) return;
var prog = findProgram(state.project, state.currentProgId);
if (!prog) return;
var rect = el.canvas.getBoundingClientRect();
var mouseX = e.clientX - rect.left;
var mouseY = e.clientY - rect.top;
// 遍历所有梯级,检测点击
for (var i = 0; i < prog.rungs.length; i++) {
var rung = prog.rungs[i];
var hitElem = hitTestElement(state.project, state.currentProgId, rung.id, mouseX, mouseY);
if (hitElem) {
state.selectedElemId = hitElem.id;
state.isDragging = true;
state.dragOffset.x = mouseX - (40 + 60 + hitElem.x);
state.dragRungId = rung.id;
state.suppressClickPlace = true;
setStatus('已选中 ' + hitElem.name);
return;
}
}
// 点击空白处取消选中
state.selectedElemId = null;
state.suppressClickPlace = false;
});
// 拖拽移动
el.canvas.addEventListener('mousemove', function(e) {
if (!state.isDragging || !state.selectedElemId || !state.currentProgId) return;
var prog = findProgram(state.project, state.currentProgId);
if (!prog || prog.rungs.length === 0) return;
var rect = el.canvas.getBoundingClientRect();
var mouseX = e.clientX - rect.left;
var rungId = state.dragRungId || (prog.rungs.length > 0 ? prog.rungs[0].id : null);
if (!rungId) return;
var newX = mouseX - 100 - state.dragOffset.x;
updateElementPosition(state.project, state.currentProgId, rungId, state.selectedElemId, newX);
});
// 释放拖拽(document 级:鼠标移出画布后松开也能正确结束)
document.addEventListener('mouseup', function() {
state.isDragging = false;
state.dragRungId = null;
});
// 点击放置元素
el.canvas.addEventListener('click', function(e) {
if (state.suppressClickPlace) { state.suppressClickPlace = false; return; }
if (!state.selectedElemType || !state.currentProgId) return;
var prog = findProgram(state.project, state.currentProgId);
if (!prog || prog.rungs.length === 0) return;
var rect = el.canvas.getBoundingClientRect();
var mouseY = e.clientY - rect.top;
var rungIdx = getRungIndex(mouseY);
if (rungIdx >= prog.rungs.length) rungIdx = prog.rungs.length - 1;
var rung = prog.rungs[rungIdx];
var x = e.clientX - rect.left - 100;
var elem = addElement(state.project, state.currentProgId, rung.id, state.selectedElemType, null, x);
setStatus('已添加 ' + (elem ? elem.name : '元素') + ' 到梯级 ' + rung.number);
state.selectedElemType = null;
});
}
交互流程:
- 点击工具栏元素按钮 → 设置 selectedElemType
- 点击画布空白区域 → 在对应梯级的鼠标 x 位置放置元素
- 点击已有元素 → 进入拖拽模式(suppressClickPlace = true 抑制本次放置)
- 拖拽移动 → 实时更新元素 x 坐标
- 松开鼠标 → 结束拖拽
- 按 Delete 键 → 删除选中元素
mouseup 监听挂载在 document 而非 canvas 上——确保鼠标移出画布区域后松开也能正确结束拖拽状态。
3.5.3 60fps 渲染循环
// ========== 渲染循环 ==========
function renderLoop() {
var ctx = el.canvas.getContext('2d');
ctx.fillStyle = '#1e1e2e';
ctx.fillRect(0, 0, el.canvas.width, el.canvas.height);
if (state.currentProgId) {
renderLadder(ctx, state.project, state.currentProgId, {
width: el.canvas.width,
height: el.canvas.height
});
// 绘制选中元素的高亮框
if (state.selectedElemId) {
drawSelectionHighlight(ctx, state.project, state.currentProgId, state.selectedElemId);
}
}
requestAnimationFrame(renderLoop);
}
// ========== 选中元素高亮 ==========
function drawSelectionHighlight(ctx, project, progId, elemId) {
var prog = findProgram(project, progId);
if (!prog) return;
for (var i = 0; i < prog.rungs.length; i++) {
var rung = prog.rungs[i];
for (var j = 0; j < rung.elements.length; j++) {
if (rung.elements[j].id === elemId) {
var elem = rung.elements[j];
var x = 40 + 60 + elem.x - 5;
var y = 30 + i * 100 + 50 - 25;
ctx.strokeStyle = '#f9e2af';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.strokeRect(x, y, 50, 50);
ctx.setLineDash([]);
return;
}
}
}
}
渲染循环采用 requestAnimationFrame 持续驱动,每帧执行:清屏 → 绘制梯形图 → 绘制选中高亮。选中高亮使用黄色虚线框(#f9e2af,setLineDash([5,3]))标记被选中元素的位置。
3.5.4 变量管理对话框
// ========== 变量编辑对话框 ==========
function bindVarDialogEvents() {
var layer = document.getElementById('varDialogLayer');
// 分段按钮组切换(替代原生下拉,规避适配层弹层崩溃)
var segGroups = document.querySelectorAll('.seg-group');
segGroups.forEach(function(group) {
var btns = group.querySelectorAll('.seg-btn');
btns.forEach(function(btn) {
btn.addEventListener('click', function() {
btns.forEach(function(b) { b.classList.toggle('active', b === btn); });
});
});
});
// 读取分段按钮组的选中值
function getSegValue(groupId) {
var active = document.querySelector('#' + groupId + ' .seg-btn.active');
return active ? active.getAttribute('data-value') : null;
}
document.getElementById('btnAddVar').addEventListener('click', function() {
layer.classList.add('open');
document.getElementById('varName').value = '';
document.getElementById('varName').focus();
});
document.getElementById('btnVarCancel').addEventListener('click', function() {
layer.classList.remove('open');
});
document.getElementById('btnVarOk').addEventListener('click', function() {
var type = getSegValue('varTypeGroup');
var name = document.getElementById('varName').value.trim();
var value = getSegValue('varValueGroup') === 'true';
if (!name) { setStatus('请输入变量名称'); return; }
if (!type) { setStatus('请选择变量类型'); return; }
// 生成完整变量名
var prefix = type === 'inputs' ? '%IX' : (type === 'outputs' ? '%QX' : '%MX');
var fullName = prefix + name;
if (Object.prototype.hasOwnProperty.call(state.project.variables[type], fullName)) {
setStatus('变量已存在: ' + fullName);
return;
}
// 添加到对应变量集合
state.project.variables[type][fullName] = value;
updateVariables();
layer.classList.remove('open');
setStatus('已添加变量 ' + fullName);
});
// 点击遮罩关闭
layer.addEventListener('click', function(e) {
if (e.target === layer) layer.classList.remove('open');
});
}
变量对话框使用分段按钮组(seg-group)替代原生 select 元素——这是鸿蒙适配的关键决策。原生 select 在 ArkWeb 引擎上会触发系统弹层,而鸿蒙适配层处理弹层时存在超时崩溃风险。分段按钮组通过 div + button 纯 DOM 实现,完全规避这一风险。
四、鸿蒙稳定性适配
4.1 状态栏反馈替代 alert
鸿蒙 ArkWeb 引擎下,alert/confirm/prompt 等原生对话框可能触发适配层异常。本项目全部使用状态栏文字反馈:
function setStatus(text) {
el.statusInfo.textContent = text;
}
所有操作结果(新建项目、打开文件、保存文件、添加元素、执行仿真等)均通过 setStatus() 在底部状态栏显示,不使用任何原生弹窗。
4.2 自定义关于弹窗
应用右下角的"关于"链接点击后弹出自定义 DOM 对话框(#aboutDialogLayer),展示开源声明、免责声明与许可证信息。对话框通过 CSS 类 open 控制显隐,点击遮罩层或"确定"按钮关闭:
function bindAboutEvents() {
var layer = document.getElementById('aboutDialogLayer');
document.getElementById('btnAbout').addEventListener('click', function() {
layer.classList.add('open');
});
document.getElementById('btnAboutClose').addEventListener('click', function() {
layer.classList.remove('open');
});
// 点击遮罩关闭
layer.addEventListener('click', function(e) {
if (e.target === layer) layer.classList.remove('open');
});
}
4.3 CSS Grid 同格叠加
对话框层通过 CSS Grid 的 grid-area 属性叠加在整个页面之上:
body {
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-columns: 1fr;
}
body > .toolbar { grid-area: 1 / 1; }
body > .main { grid-area: 2 / 1; }
body > .statusbar { grid-area: 3 / 1; }
body > .dialog-layer { grid-area: 1 / 1 / 4 / 2; }
dialog-layer 跨越全部三行(从第 1 行到第 4 行线),覆盖整个视口。这种方案避免了 absolute/fixed 定位——鸿蒙 ArkWeb 引擎对绝对定位的层级叠放支持不稳定,Grid 同格叠加是更可靠的替代方案。
4.4 Catppuccin Mocha 深色主题
样式文件采用 Catppuccin Mocha 配色方案,通过 CSS 变量统一管理:
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--surface0: #313244;
--surface1: #45475a;
--surface2: #585b70;
--text: #cdd6f4;
--text-muted: #a6adc8;
--overlay0: #6c7086;
--blue: #89b4fa;
--green: #a6e3a1;
--red: #f38ba8;
--yellow: #f9e2af;
--mauve: #cba6f7;
--peach: #fab387;
--teal: #94e2d5;
--sky: #89dceb;
--pink: #f5c2e7;
--border: #45475a;
--border-light: #585b70;
--toolbar-height: 44px;
--statusbar-height: 26px;
--sidebar-width: 180px;
--properties-width: 240px;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Consolas', 'Monaco', monospace;
}
主题配色在梯形图元素上的映射:
- 触点(contact):绿色 #a6e3a1
- 线圈(coil):橙色 #fab387
- 定时器(timer):紫色 #cba6f7
- 计数器(counter):黄色 #f9e2af
- 母线(rail):蓝色 #89b4fa
- 导线(wire):文本色 #cdd6f4
- 选中高亮:黄色 #f9e2af 虚线
4.5 应用名称配置
应用名称需在多个位置保持一致:
// AppScope/resources/base/element/string.json
{
"string": [
{
"name": "app_name",
"value": "逻控 PLC 编辑器"
}
]
}
// electron/src/main/resources/base/element/string.json
{
"string": [
{
"name": "EntryAbility_label",
"value": "逻控 PLC 编辑器"
}
]
}
// electron/src/main/resources/zh_CN/element/string.json
{
"string": [
{
"name": "EntryAbility_label",
"value": "逻控 PLC 编辑器"
}
]
}
// electron/src/main/resources/en_US/element/string.json
{
"string": [
{
"name": "EntryAbility_label",
"value": "逻控 PLC 编辑器"
}
]
}
注意:en_US 目录下 EntryAbility_label 也必须是中文——鸿蒙资源覆盖规则是 locale 目录优先于 base 目录,如果 en_US 留英文,英文系统语言下会显示英文名称而非中文品牌名。
4.6 示例程序
应用内置一套电机启动/停止控制示例程序,演示常开触点、常闭触点、定时器与线圈的联动:
function loadExample() {
state.project = createPlcProject();
// 预设变量
state.project.variables.inputs['%IX0.0'] = false; // 启动按钮
state.project.variables.inputs['%IX0.1'] = false; // 停止按钮
state.project.variables.outputs['%QX0.0'] = false; // 电机
state.project.variables.outputs['%QX0.1'] = false; // 定时完成标志
state.project.variables.markers['%MX0.0'] = false; // 内部辅助位
// 程序 1:电机启动/停止控制
var prog1 = addProgram(state.project, '电机控制');
var rung1 = addRung(state.project, prog1.id);
// 梯级 1:启动按钮 + 停止按钮 + 电机线圈
addElement(state.project, prog1.id, rung1.id, 'contact_no', '%IX0.0', 0);
addElement(state.project, prog1.id, rung1.id, 'contact_nc', '%IX0.1', 80);
addElement(state.project, prog1.id, rung1.id, 'coil', '%QX0.0', 160);
var rung2 = addRung(state.project, prog1.id);
// 梯级 2:定时器 + 完成标志
addElement(state.project, prog1.id, rung2.id, 'contact_no', '%IX0.0', 0);
addElement(state.project, prog1.id, rung2.id, 'timer_ton', '%T0', 80);
addElement(state.project, prog1.id, rung2.id, 'coil', '%QX0.1', 180);
state.currentProgId = prog1.id;
clearSelection();
renderProgramList();
updateVariables();
setStatus('已加载示例:电机启动/停止控制(点击运行查看效果)');
}
五、构建与部署


5.1 构建 HAP 包
在 DevEco Studio 中执行 Build → Build Hap(s),生成 unsigned HAP 包。由于本项目使用 Electron 壳方案,构建流程与标准鸿蒙应用一致。
5.2 签名配置
Release 签名需要配置 build-profile.json5:
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "LuokongPlc.p12",
"keyAlias": "luokongplc1",
"signAlg": "SHA256withECDSA",
"profile": "LuokongPlcRelease.p7b",
"certpath": "LuokongPlc.cer"
}
}]
5.3 部署目录同步
业务代码的开发目录与部署目录分离,每次修改后需同步到 web_engine 的 resfile 目录:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/OpenPLC/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
六、常见问题与解决方案
Q1:AGC 审核驳回——应用名称与包名不一致
问题现象: 提交 AGC 审核后收到驳回意见,指出应用显示名称与包名或内部配置不一致。
根因分析: AppScope/app.json5 中的 bundleName、app_name,以及三套 EntryAbility_label(base/zh_CN/en_US)未统一。
解决: 确保以下四处名称完全一致:
- AppScope/app.json5 → bundleName: com.wei.luokongplc
- AppScope/resources/base/element/string.json → app_name: 逻控 PLC 编辑器
- electron/src/main/resources/base/element/string.json → EntryAbility_label: 逻控 PLC 编辑器
- electron/src/main/resources/en_US/element/string.json → EntryAbility_label: 逻控 PLC 编辑器
- electron/src/main/resources/zh_CN/element/string.json → EntryAbility_label: 逻控 PLC 编辑器
教训: 鸿蒙资源覆盖规则是 locale 目录 > base 目录,修改应用名时必须全局 grep 所有 element/string.json 文件,不能只改 base。
Q2:构建签名报错 ERROR: 11014001 key alias not found
问题现象: 在 DevEco Studio 中执行 Build → Build Hap(s),SignHap 阶段报错 ERROR: 11014001,提示签名密钥别名找不到。
根因分析: build-profile.json5 中 signingConfigs.default.material.keyAlias 填写的值与 .p12 签名文件内实际存储的别名不一致。
解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名:
"%DEVECO_JBR%\jbr\bin\keytool" -list -keystore "LuokongPlc.p12" -storepass 你的密码
查看输出中的 Alias Name,然后填入 build-profile.json5:
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "C:/path/to/LuokongPlc.p12",
"keyAlias": "luokongplc1", // 必须与 p12 内真实别名完全一致
"signAlg": "SHA256withECDSA",
"profile": "C:/path/to/LuokongPlcRelease.p7b",
"certpath": "C:/path/to/LuokongPlc.cer"
}
}]
Q3:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限
问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了受限 ACL 权限,但应用隐私政策中未声明对应用途。
根因分析: Electron 壳工程模板默认声明了大量系统权限,逻控 PLC 编辑器作为梯形图编程工具不需要位置、相机、麦克风等权限。
解决: 精简权限声明,只保留应用实际使用的 14 项系统权限(INTERNET、GET_NETWORK_INFO、ACCESS_CERT_MANAGER、RUNNING_LOCK、PRINT、PREPARE_APP_TERMINATE、ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST、GYROSCOPE、ACCELEROMETER、SET_ABILITY_INSTANCE_INFO、GET_FILE_ICON、LOCK_WINDOW_CURSOR),移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。
Q4:修改了代码,但 HAP 安装后没有生效
问题现象: 修改了 renderer.js、plc-engine.js 等文件,重新构建 HAP 安装后,应用行为没有任何变化。
根因分析: 鸿蒙壳工程的实际运行文件从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载,而非开发目录。修改后必须手动同步。
解决: 每次修改后执行同步命令:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/OpenPLC/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
验证方法: 同步后用文件哈希对比确认一致性。
Q5:Canvas 拖拽元素时鼠标移出画布后状态卡死
问题现象: 拖拽梯形图元素时,如果鼠标移出 Canvas 区域后松开,元素拖拽状态不会结束,后续点击画布任意位置都会触发元素移动。
根因分析: mouseup 事件仅绑定在 canvas 元素上,鼠标在 canvas 外部松开时不会触发事件,isDragging 状态无法重置。
解决: 将 mouseup 监听挂载在 document 上而非 canvas 上,确保无论鼠标在何处松开都能正确结束拖拽:
// 释放拖拽(document 级:鼠标移出画布后松开也能正确结束)
document.addEventListener('mouseup', function() {
state.isDragging = false;
state.dragRungId = null;
});
Q6:仿真运行后变量面板数值不刷新
问题现象: 点击"运行"按钮执行仿真后,右侧变量面板中各变量的 TRUE/FALSE 状态没有更新。
根因分析: runSimulation() 中调用了 executeProgram() 执行逻辑扫描,但遗漏了后续的 updateVariables() 调用——变量面板的数据需要手动触发刷新。
解决: 在仿真执行完成后显式调用 updateVariables():
function runSimulation() {
if (!state.currentProgId) { setStatus('请先选择程序'); return; }
var result = executeProgram(state.project, state.currentProgId);
if (result.success) {
renderLog(result.log);
updateVariables(); // 刷新变量面板
// 自动切换到日志标签
var logTabBtn = document.querySelector('.tab-btn[data-tab="log"]');
if (logTabBtn) logTabBtn.click();
setStatus('执行完成:' + result.log.length + ' 个梯级');
} else {
setStatus('执行失败: ' + result.error);
}
}
Q7:分段按钮组点击后选中状态不切换
问题现象: 在添加变量对话框中,点击分段按钮(输入 %IX / 输出 %QX / 内部位 %MX)后,选中样式没有正确切换。
根因分析: 分段按钮组的点击事件中使用 classList.toggle(‘active’, condition) 时,condition 表达式写错,导致所有按钮同时被添加或移除 active 类。
解决: 确保 toggle 的第二个参数精确判断当前按钮是否为被点击的目标:
segGroups.forEach(function(group) {
var btns = group.querySelectorAll('.seg-btn');
btns.forEach(function(btn) {
btn.addEventListener('click', function() {
btns.forEach(function(b) { b.classList.toggle('active', b === btn); });
});
});
});
b === btn 确保只有被点击的按钮获得 active 类,其余按钮移除 active 类。
七、总结
本文完整记录了在鸿蒙 PC 平台上适配逻控 PLC 编辑器的全过程。通过 Electron 壳方案,我们实现了一个功能完整的纯 JavaScript 梯形图编程与仿真环境,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| 梯形图引擎 | 七种元素类型 + 数据模型 + 梯级/程序管理 |
| Canvas 渲染 | 左右母线 + 分段导线 + 触点/线圈/定时器/计数器符号 + 选中高亮 |
| 仿真执行 | 逐梯级扫描 + TON/TOF 时序 + CTU/CTD 边沿检测 |
| 变量系统 | %IX/%QX/%MX/%T/%C 五类 + 分段按钮组添加对话框 |
| 元素交互 | 点击放置 + 拖拽移动 + Delete 删除 + 命中测试 |
| 文件读写 | .plc 项目打开/保存 + JSON 序列化 + IPC 通道 |
| 示例程序 | 电机启动/停止控制 + 定时器联动 |
| 鸿蒙稳定 | 禁用默认菜单 + 自定义关于弹窗 + 三防窗口 + 状态栏反馈 |
适配过程中最关键的三个点:
- Canvas 2D 实时渲染——梯形图图形完全通过 Canvas 2D API 实时绘制,采用分层绘制策略(先导线后元素),每帧通过 requestAnimationFrame 驱动,在鸿蒙 ArkWeb 引擎上兼容性良好
- 仿真引擎时序逻辑——TON/TOF 定时器基于 Date.now() 时间戳实现真实时序行为,CTU/CTD 计数器通过上升沿检测实现边沿触发,这些逻辑在纯 JavaScript 环境下完整复现了 PLC 扫描周期的核心特征
- document 级 mouseup 监听——拖拽交互的结束事件必须挂载在 document 而非 canvas 上,否则鼠标移出画布后松开会导致状态卡死,这是 Canvas 交互应用的通用最佳实践
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 梯形图编程与仿真环境。
更多推荐



所有评论(0)