逻控 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++ / PythonHTML5/Canvas/Vanilla JavaScript
文件操作本地文件 I/ONode.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(); });

关键点说明:

  1. disableHardwareAcceleration——鸿蒙 ArkWeb 引擎下 GPU 硬件加速可能导致白屏或渲染异常,必须在 app ready 之前调用禁用
  2. Menu.setApplicationMenu(null) + mainWindow.setMenu(null) 双保险——彻底移除 Electron 默认菜单栏,避免鸿蒙下菜单渲染引发崩溃
  3. frame: true / transparent: false / resizable: true 三防配置——保留系统原生标题栏、禁止透明窗口、允许用户调整尺寸,这三项组合是鸿蒙上窗口稳定运行的基线
  4. try-catch 包裹 createWindow——鸿蒙下 SubWindow 创建可能超时,未捕获异常会导致主进程直接崩溃
  5. 窗口尺寸按主显示区域 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;
  });
}

交互流程:

  1. 点击工具栏元素按钮 → 设置 selectedElemType
  2. 点击画布空白区域 → 在对应梯级的鼠标 x 位置放置元素
  3. 点击已有元素 → 进入拖拽模式(suppressClickPlace = true 抑制本次放置)
  4. 拖拽移动 → 实时更新元素 x 坐标
  5. 松开鼠标 → 结束拖拽
  6. 按 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 通道
示例程序电机启动/停止控制 + 定时器联动
鸿蒙稳定禁用默认菜单 + 自定义关于弹窗 + 三防窗口 + 状态栏反馈

适配过程中最关键的三个点:

  1. Canvas 2D 实时渲染——梯形图图形完全通过 Canvas 2D API 实时绘制,采用分层绘制策略(先导线后元素),每帧通过 requestAnimationFrame 驱动,在鸿蒙 ArkWeb 引擎上兼容性良好
  2. 仿真引擎时序逻辑——TON/TOF 定时器基于 Date.now() 时间戳实现真实时序行为,CTU/CTD 计数器通过上升沿检测实现边沿触发,这些逻辑在纯 JavaScript 环境下完整复现了 PLC 扫描周期的核心特征
  3. document 级 mouseup 监听——拖拽交互的结束事件必须挂载在 document 而非 canvas 上,否则鼠标移出画布后松开会导致状态卡死,这是 Canvas 交互应用的通用最佳实践

这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 梯形图编程与仿真环境。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐