浏览代码

feat: 完善运行模式与项目功能

main
suyu 2 周前
父节点
当前提交
3bd8c15835
共有 81 个文件被更改,包括 2850 次插入415 次删除
  1. +4
    -0
      .gitignore
  2. +2
    -2
      AGENTS.md
  3. +2
    -0
      app/integrated_platform.pro
  4. +5
    -0
      app/src/domain/active_register_repository.cpp
  5. +5
    -1
      app/src/infrastructure/application_settings_loader.h
  6. +15
    -2
      app/src/infrastructure/json_project_storage.h
  7. +6
    -2
      app/src/infrastructure/plc_communication_error_classifier.h
  8. +29
    -0
      app/src/infrastructure/plc_communication_service.cpp
  9. +85
    -15
      app/src/infrastructure/plc_communication_service.h
  10. +6
    -0
      app/src/infrastructure/plc_discovery_service.cpp
  11. +31
    -6
      app/src/infrastructure/plc_discovery_service.h
  12. +16
    -0
      app/src/infrastructure/plc_register_repository.cpp
  13. +51
    -2
      app/src/infrastructure/plc_register_repository.h
  14. +15
    -0
      app/src/infrastructure/runtime_project_bundle.cpp
  15. +20
    -2
      app/src/infrastructure/runtime_project_bundle.h
  16. +18
    -0
      app/src/infrastructure/runtime_settings_loader.cpp
  17. +16
    -2
      app/src/infrastructure/runtime_settings_loader.h
  18. +14
    -1
      app/src/main.cpp
  19. +9
    -0
      app/src/services/alarm_editor_service.cpp
  20. +10
    -9
      app/src/services/alarm_editor_service.h
  21. +6
    -0
      app/src/services/alarm_service.cpp
  22. +8
    -7
      app/src/services/application_settings.h
  23. +37
    -4
      app/src/services/hmi_editor_service.cpp
  24. +9
    -8
      app/src/services/hmi_editor_service.h
  25. +5
    -0
      app/src/services/hmi_navigation_service.cpp
  26. +9
    -9
      app/src/services/hmi_navigation_service.h
  27. +20
    -0
      app/src/services/hmi_runtime_service.cpp
  28. +8
    -6
      app/src/services/hmi_runtime_service.h
  29. +19
    -0
      app/src/services/logic_command_service.cpp
  30. +57
    -52
      app/src/services/logic_command_service.h
  31. +121
    -8
      app/src/services/logic_editor_service.cpp
  32. +163
    -77
      app/src/services/logic_editor_service.h
  33. +13
    -0
      app/src/services/offline_simulation_service.cpp
  34. +16
    -0
      app/src/services/online_logic_monitor_service.cpp
  35. +33
    -19
      app/src/services/online_logic_monitor_service.h
  36. +7
    -0
      app/src/services/plc_discovery_gateway.cpp
  37. +7
    -7
      app/src/services/plc_discovery_gateway.h
  38. +14
    -0
      app/src/services/project_service.cpp
  39. +6
    -0
      app/src/services/project_service.h
  40. +7
    -0
      app/src/services/register_comment_service.cpp
  41. +9
    -6
      app/src/services/register_comment_service.h
  42. +34
    -0
      app/src/services/register_monitor_service.cpp
  43. +27
    -4
      app/src/services/register_monitor_service.h
  44. +51
    -0
      app/src/services/runtime_mode_service.cpp
  45. +3
    -0
      app/src/services/runtime_mode_service.h
  46. +21
    -0
      app/src/services/software_logic_executor.cpp
  47. +36
    -0
      app/src/services/user_runtime_plc_recovery_policy.cpp
  48. +23
    -0
      app/src/services/user_runtime_plc_recovery_policy.h
  49. +14
    -0
      app/src/ui/alarm_configuration_dialog.cpp
  50. +8
    -0
      app/src/ui/button_extension_dialog.cpp
  51. +15
    -0
      app/src/ui/button_extension_dialog.h
  52. +36
    -0
      app/src/ui/free_monitor_widget.cpp
  53. +2
    -0
      app/src/ui/free_monitor_widget.h
  54. +58
    -1
      app/src/ui/hmi_editor_widget.cpp
  55. +10
    -38
      app/src/ui/hmi_editor_widget.h
  56. +114
    -0
      app/src/ui/logic_editor_widget.cpp
  57. +127
    -51
      app/src/ui/logic_editor_widget.h
  58. +11
    -0
      app/src/ui/logic_instruction_dialog.cpp
  59. +181
    -32
      app/src/ui/main_window.cpp
  60. +26
    -23
      app/src/ui/main_window.h
  61. +16
    -0
      app/src/ui/plc_connection_dialog.cpp
  62. +16
    -0
      app/src/ui/project_workspace_controller.cpp
  63. +22
    -0
      app/src/ui/property_panel_controller.cpp
  64. +11
    -0
      app/src/ui/register_comment_dialog.cpp
  65. +16
    -0
      app/src/ui/runtime_monitor_widget.cpp
  66. +10
    -0
      app/src/ui/runtime_monitor_window.cpp
  67. +23
    -0
      app/src/ui/runtime_panel_controller.cpp
  68. +14
    -0
      app/src/ui/status_text_dialog.cpp
  69. +12
    -2
      app/src/ui/status_text_dialog.h
  70. +10
    -0
      app/src/ui/toolbar_icon_factory.cpp
  71. +12
    -3
      app/tests/domain_tests.cpp
  72. +2
    -2
      app/tests/logic_editor_service_tests.cpp
  73. +2
    -0
      app/tests/pri/test_layers.pri
  74. +33
    -0
      app/tests/runtime_mode_service_tests.cpp
  75. +1
    -1
      app/tests/runtime_project_bundle_tests.cpp
  76. +14
    -9
      docs/ai/handoff.md
  77. +2
    -0
      docs/architecture.md
  78. +897
    -0
      docs/代码功能阅读清单.md
  79. +1
    -1
      docs/测试约定.md
  80. +3
    -1
      docs/用户使用/用户运行程序导出说明.md
  81. +3
    -0
      注释标准.md

+ 4
- 0
.gitignore 查看文件

@@ -45,6 +45,8 @@ tmp/
# Local draft documents, not part of the project source of truth
/docs/需求规格书.md
/docs/设计方案书.md
*.doc
*.docx

# Local secrets and machine-specific configuration
.env
@@ -70,3 +72,5 @@ Desktop.ini
/other_version
/app_copy
当前窗口需求.md
/类图Mermaid
/苏宇_综合平台编程器_2026.8.31

+ 2
- 2
AGENTS.md 查看文件

@@ -48,7 +48,7 @@
- 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition`
- 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/`
- 项目编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`
- 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release`
- 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release -Suite All`
- Qt 项目打包:`pwsh -NoLogo -NoProfile -File .\scripts\package_qt_app.ps1`
- 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'`

@@ -56,7 +56,7 @@

- 新功能和重构默认不兼容旧代码;直接同步修改现有调用方,不为旧调用方式保留默认参数、重载、适配层、回退分支或废弃接口,除非用户明确要求兼容
- 生产代码只提供实际业务运行需要的接口;不得为了让测试少改代码或方便构造测试对象,在生产代码中增加无业务用途的公开接口、默认构造入口、全局默认对象或其他测试专用兼容逻辑,测试应通过测试夹具、显式依赖注入或测试替身适配生产接口
- 当前工程 JSON 只支持严格的 `formatVersion: "3.0"`;以后修改 JSON 格式时直接将生产代码、测试和格式文档整体升级到新的唯一版本,不为旧工程格式保留读取、写入、迁移、双版本解析或兼容分支,旧版本文件应明确拒绝,除非用户明确要求兼容
- 当前工程 JSON 只支持严格的 `formatVersion: "4.0"`;以后修改 JSON 格式时直接将生产代码、测试和格式文档整体升级到新的唯一版本,不为旧工程格式保留读取、写入、迁移、双版本解析或兼容分支,旧版本文件应明确拒绝,除非用户明确要求兼容

## 代码与架构约束



+ 2
- 0
app/integrated_platform.pro 查看文件

@@ -53,6 +53,7 @@ SOURCES += \
src/services/offline_simulation_service.cpp \
src/services/online_logic_monitor_service.cpp \
src/services/runtime_mode_service.cpp \
src/services/user_runtime_plc_recovery_policy.cpp \
src/services/plc_discovery_gateway.cpp \
src/services/register_monitor_service.cpp \
src/services/register_comment_service.cpp \
@@ -109,6 +110,7 @@ HEADERS += \
src/services/offline_simulation_service.h \
src/services/online_logic_monitor_service.h \
src/services/runtime_mode_service.h \
src/services/user_runtime_plc_recovery_policy.h \
src/services/plc_discovery_gateway.h \
src/services/register_monitor_service.h \
src/services/register_comment_service.h \


+ 5
- 0
app/src/domain/active_register_repository.cpp 查看文件

@@ -24,12 +24,14 @@ BitReadResult ActiveRegisterRepository::readBit(const RegisterAddress &address)
RegisterWriteResult ActiveRegisterRepository::writeBit(
const RegisterAddress &address, bool value)
{
// 真机模式会转发到 PLC 仓库,离线模式会转发到虚拟仓库
return repository_->writeBit(address, value);
}

// 将单个 D 字读取请求转发给当前活动仓库
WordReadResult ActiveRegisterRepository::readWord(const RegisterAddress &address) const
{
// 真机模式读取 PLC 最近一次成功轮询的 D 缓存
return repository_->readWord(address);
}

@@ -37,6 +39,7 @@ WordReadResult ActiveRegisterRepository::readWord(const RegisterAddress &address
RegisterWriteResult ActiveRegisterRepository::writeWord(
const RegisterAddress &address, std::int16_t value)
{
// 写入目标由当前运行模式选择,代理本身不保存 D 值
return repository_->writeWord(address, value);
}

@@ -44,6 +47,7 @@ RegisterWriteResult ActiveRegisterRepository::writeWord(
WordsReadResult ActiveRegisterRepository::readWords(
const RegisterAddress &address, int count) const
{
// 连续 D 读取保持同一活动仓库,避免跨数据源拼接数值
return repository_->readWords(address, count);
}

@@ -52,5 +56,6 @@ RegisterWriteResult ActiveRegisterRepository::writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values)
{
// 连续 D 写入整体转发,真机模式由 PLC 通信层一次发送
return repository_->writeWords(address, values);
}

+ 5
- 1
app/src/infrastructure/application_settings_loader.h 查看文件

@@ -10,6 +10,10 @@ class ApplicationSettingsLoader final
public:
// 返回程序目录下应用配置文件的默认路径
static QString defaultFilePath();
// 读取指定 INI 文件,不存在时创建默认文件
/**
* @brief 读取指定 INI 文件,不存在时创建默认文件
* @param file_path 应用配置文件的完整路径
* @return 配置加载结果,包含实际配置、过程消息和警告信息
*/
static ApplicationSettingsLoadResult load(const QString &file_path);
};

+ 15
- 2
app/src/infrastructure/json_project_storage.h 查看文件

@@ -6,12 +6,25 @@
class JsonProjectStorage final : public ProjectStorage
{
public:
/**
* @brief 创建严格 JSON 4.0 工程存储
* @param project_limits 保存和加载时使用的工程数量限制
*/
explicit JsonProjectStorage(
const ProjectLimitSettings &project_limits);
// 校验并以 JSON 4.0 格式原子保存工程
/**
* @brief 校验并以 JSON 4.0 格式原子保存工程
* @param project 要保存的完整工程
* @param file_path 目标 JSON 文件路径
* @return 保存结果,失败时包含存储错误类型和具体原因
*/
ProjectSaveResult save(
const Project &project, const std::string &file_path) override;
// 从 JSON 4.0 文件读取并校验工程
/**
* @brief 从 JSON 4.0 文件读取并校验工程
* @param file_path 要读取的 JSON 文件路径
* @return 加载结果,成功时包含完整工程,失败时包含具体原因
*/
ProjectLoadResult load(const std::string &file_path) override;

private:


+ 6
- 2
app/src/infrastructure/plc_communication_error_classifier.h 查看文件

@@ -20,8 +20,12 @@ struct PlcCommunicationFailure
QString message; // 面向用户显示的错误说明
};

// 将 Qt Modbus 错误和上下文转换为项目统一错误类型
// 通过串口状态和 PLC 响应状态区分具体通信故障
/**
* @brief 将 Qt Modbus 错误和现场上下文转换为项目统一通信故障
* @param error Qt Modbus 返回的设备错误
* @param context 串口会话和 PLC 有效响应状态
* @return 分类后的项目错误类型和用户提示文字
*/
PlcCommunicationFailure classifyPlcCommunicationError(
QModbusDevice::Error error,
const PlcCommunicationErrorContext &context);

+ 29
- 0
app/src/infrastructure/plc_communication_service.cpp 查看文件

@@ -160,20 +160,24 @@ PlcCommunicationService::PlcCommunicationService(
repository_.setWriteHandlers(
[this](const RegisterAddress &address, bool value)
{
// PLC 仓库收到 M 写入时转成 Modbus 线圈单写请求
return sendBitWrite(address, value);
},
[this](const RegisterAddress &address, std::int16_t value)
{
// PLC 仓库收到单个 D 写入时转成保持寄存器单写请求
return sendWordWrite(address, value);
},
[this](const RegisterAddress &address,
const std::vector<std::int16_t> &values)
{
// PLC 仓库收到连续 D 写入时转成保持寄存器多写请求
return sendWordsWrite(address, values);
});
// 轮询定时器每次只推动一个读块,避免一次压入大量异步请求
connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock);
recovery_timer_.setSingleShot(true);
// PLC 超时进入 Faulted 后,由恢复定时器调用轻量探测入口
connect(
&recovery_timer_,
&QTimer::timeout,
@@ -190,6 +194,7 @@ PlcCommunicationService::PlcCommunicationService(
serial_session_opened_ = true;
setState(PlcConnectionState::Connected);
poll_timer_.start(configuration_.pollIntervalMs);
// 串口连上后立即异步读取第一个 M/D 地址块
pollNextBlock();
}
else if (device_state == QModbusDevice::ConnectingState)
@@ -221,6 +226,7 @@ PlcCommunicationService::PlcCommunicationService(
{
if (error != QModbusDevice::NoError)
{
// 底层 Modbus 错误统一交给项目错误分类和恢复流程
handleModbusError(error);
}
});
@@ -278,12 +284,14 @@ PlcCommunicationResult PlcCommunicationService::connectDevice(
updateInitialReadCompleted(false, true);
rebuildPollBlocks();
setState(PlcConnectionState::Connecting);
// 只发起异步串口连接,连接结果由 Qt 状态回调继续推进
if (!master_->connectDevice())
{
if (last_error_.empty())
{
const QModbusDevice::Error error = master_->error() == QModbusDevice::NoError
? QModbusDevice::ConnectionError : master_->error();
// 串口连接请求启动失败也进入统一错误分类和状态更新
handleModbusError(error);
}
return {false, last_error_};
@@ -485,6 +493,7 @@ void PlcCommunicationService::applyPollAddresses(
pending_poll_multi_word_ranges_.clear();
if (isReadingState(state_) && pending_reply_ == nullptr)
{
// 新轮询地址生效后立即从第一个读块继续异步轮询
pollNextBlock();
}
}
@@ -503,9 +512,11 @@ void PlcCommunicationService::pollNextBlock()
next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size();
QModbusDataUnit request(
registerType(block.area), block.startAddress, static_cast<quint16>(block.count));
// 发出当前 M 或 D 地址块的异步 Modbus 读取请求
QModbusReply *reply = master_->sendReadRequest(request, configuration_.serverAddress);
if (reply == nullptr)
{
// 读请求未能创建时按通信故障撤销首读资格
handleModbusError(master_->error());
return;
}
@@ -522,6 +533,7 @@ void PlcCommunicationService::pollNextBlock()
reply->deleteLater();
return;
}
// 读取完成后解析回复并只用成功读回值更新 PLC 缓存
handleReadFinished(reply, block);
if (isReadingState(state_)
&& reply->error() == QModbusDevice::NoError
@@ -636,6 +648,7 @@ void PlcCommunicationService::probeRecovery()
handleRecoveryProbeFailure(error);
return;
}
// 单地址探测成功后恢复正常轮询,并重新开始完整首读
restoreCommunication();
});
}
@@ -654,6 +667,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er
QString::fromStdString(configuration_.portName),
serial_session_opened_,
received_valid_response_};
// 探测阶段发现串口连接已经失效时,转入统一故障处理
setError(classifyPlcCommunicationError(error, context));
return;
}
@@ -670,6 +684,7 @@ void PlcCommunicationService::restoreCommunication()
rebuildPollBlocks();
setState(PlcConnectionState::Recovering);
poll_timer_.start(configuration_.pollIntervalMs);
// 恢复后从第一个地址块重新开始完整首读
pollNextBlock();
}

@@ -683,6 +698,7 @@ void PlcCommunicationService::handleReadFinished(
}
if (reply->error() != QModbusDevice::NoError)
{
// 轮询回复失败时统一记录故障并启动对应恢复路径
handleModbusError(reply->error());
return;
}
@@ -693,10 +709,12 @@ void PlcCommunicationService::handleReadFinished(
const int address = block.startAddress + static_cast<int>(index);
if (block.area == RegisterArea::M)
{
// M 地址只用成功读回的线圈值刷新缓存
repository_.updateBit(address, result.value(index) != 0U);
}
else
{
// D 地址只用成功读回的保持寄存器值刷新缓存
repository_.updateWord(address, static_cast<std::int16_t>(result.value(index)));
}
}
@@ -726,9 +744,11 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
// M 区对应 Modbus Coils,单次只写一个地址
QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1);
unit.setValue(0, value ? 1U : 0U);
// 异步发送 Modbus 线圈单写,成功后仍等待轮询读回确认
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
if (reply == nullptr)
{
// M 写请求未能创建时进入统一通信故障处理
handleModbusError(master_->error());
return {false, RegisterError::WriteRejected};
}
@@ -749,6 +769,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
}
if (reply->error() != QModbusDevice::NoError)
{
// M 异步写回复失败时进入统一通信故障处理
handleModbusError(reply->error());
}
reply->deleteLater();
@@ -772,9 +793,11 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
// D 区对应 Modbus HoldingRegisters,单次只写一个字
QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1);
unit.setValue(0, static_cast<quint16>(value));
// 异步发送保持寄存器单写,成功后不直接修改本地缓存
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
if (reply == nullptr)
{
// 单个 D 写请求未能创建时进入统一通信故障处理
handleModbusError(master_->error());
return {false, RegisterError::WriteRejected};
}
@@ -794,6 +817,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
}
if (reply->error() != QModbusDevice::NoError)
{
// 单个 D 异步写回复失败时进入统一通信故障处理
handleModbusError(reply->error());
}
reply->deleteLater();
@@ -825,9 +849,11 @@ RegisterWriteResult PlcCommunicationService::sendWordsWrite(
unit.setValue(
offset, static_cast<quint16>(values[static_cast<std::size_t>(offset)]));
}
// 异步发送连续保持寄存器多写,供 32 位和 64 位数值使用
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
if (reply == nullptr)
{
// 连续 D 写请求未能创建时进入统一通信故障处理
handleModbusError(master_->error());
return {false, RegisterError::WriteRejected};
}
@@ -847,6 +873,7 @@ RegisterWriteResult PlcCommunicationService::sendWordsWrite(
}
if (reply->error() != QModbusDevice::NoError)
{
// 连续 D 异步写回复失败时进入统一通信故障处理
handleModbusError(reply->error());
}
reply->deleteLater();
@@ -877,6 +904,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect()
// 这里表示设备原本连上过,后来串口意外断开
serial_session_opened_ = false;
const QString port_name = QString::fromStdString(configuration_.portName).trimmed();
// 将 USB 串口拔出等异常记录为 Disconnected,交给运行版定时重连
setError({
PlcCommunicationError::SerialConnectionLost,
QStringLiteral(
@@ -901,6 +929,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error)
QString::fromStdString(configuration_.portName),
serial_session_opened_,
received_valid_response_};
// 将 PLC 无响应等错误记录为 Faulted,并启动通信恢复探测
setError(classifyPlcCommunicationError(error, context));
}



+ 85
- 15
app/src/infrastructure/plc_communication_service.h 查看文件

@@ -31,22 +31,39 @@ class PlcCommunicationService final : public QObject, public PlcCommunicationGat
Q_OBJECT

public:
// 创建通信服务,并把 PLC 缓存仓库交给它管理
/**
* @brief 创建通信服务,并把 PLC 缓存仓库交给它管理
* @param repository 保存 PLC 最近一次成功读数的缓存仓库
* @param parent Qt 父对象
*/
explicit PlcCommunicationService(
PlcRegisterRepository &repository,
QObject *parent = nullptr);
// 释放 Modbus 主站和未完成的通信资源
~PlcCommunicationService() override;

// 按给定串口参数连接 PLC,并开始异步轮询
/**
* @brief 按给定串口参数连接 PLC,并开始异步轮询
* @param configuration 串口、通信格式和 PLC 站号配置
* @return 连接请求启动结果,最终连接状态通过状态通知返回
*/
PlcCommunicationResult connectDevice(
const PlcSerialConfiguration &configuration) override;
// 停止轮询并断开 PLC
void disconnectDevice() override;
// 设置需要周期性读取的 M/D 地址集合
/**
* @brief 设置需要周期性读取的 M/D 地址集合
* @param addresses 要轮询的寄存器地址,服务内部负责去重和合并
* @return 地址校验和轮询集合更新结果
*/
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) override;
// 设置轮询地址及需要保持完整边界的多字数据范围
/**
* @brief 设置轮询地址及需要保持完整边界的多字数据范围
* @param addresses 要轮询的寄存器地址
* @param multi_word_ranges 不能被拆散的连续 D 字数据范围
* @return 地址和多字范围校验以及轮询集合更新结果
*/
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterWordRange> &multi_word_ranges) override;
@@ -59,7 +76,14 @@ public:
PlcCommunicationError lastErrorType() const override;
// 返回最近一次通信错误的文字
const std::string &lastError() const override;
// 注册状态、首读、缓存更新和错误通知回调
/**
* @brief 注册状态、首读、缓存更新、轮询完成和错误通知回调
* @param state_changed PLC 连接状态变化时调用
* @param initial_read_changed 首次完整读取资格变化时调用
* @param cache_updated 任一轮询块成功写入缓存后调用
* @param poll_cycle_completed 全部轮询块成功更新一轮后调用
* @param error_reported 产生需要展示的通信错误时调用
*/
void setCallbacks(
std::function<void()> state_changed,
std::function<void(bool)> initial_read_changed,
@@ -70,48 +94,94 @@ public:
signals:
// 连接状态发生变化
void stateChanged();
// 首次完整读取资格发生变化
/**
* @brief 首次完整读取资格发生变化
* @param completed 是否已经完成全部轮询块的首次读取
*/
void initialReadCompletedChanged(bool completed);
// PLC 缓存收到新的成功读数
void cacheUpdated();
// 全部轮询块成功更新一轮
void pollCycleCompleted();
// 通信错误的可读提示文字
/**
* @brief 发出通信错误的可读提示文字
* @param message 可直接显示给用户的错误说明
*/
void communicationError(const QString &message);

private:
// 把去重后的地址集合压缩为有限数量的连续读块
void rebuildPollBlocks();
// 应用新的轮询集合;有请求在途时由 pending_* 延后应用
/**
* @brief 应用新的轮询集合,有请求在途时由 pending 字段延后应用
* @param addresses 已经完成校验和去重的轮询地址
*/
void applyPollAddresses(const std::vector<RegisterAddress> &addresses);
// 发送下一段异步读取请求
void pollNextBlock();
// Faulted 状态下用轻量探测判断通信是否恢复
void probeRecovery();
// 处理恢复探测失败,并安排下一次探测
/**
* @brief 处理恢复探测失败,并安排下一次探测
* @param error Qt Modbus 返回的探测错误
*/
void handleRecoveryProbeFailure(QModbusDevice::Error error);
// 探测成功后恢复轮询,但仍需重新完成首读
void restoreCommunication();
// 处理读回复并把值写入 PLC 缓存
/**
* @brief 处理读回复并把成功读到的值写入 PLC 缓存
* @param reply 当前轮询块的异步 Modbus 回复
* @param block 发起本次请求时对应的轮询块
*/
void handleReadFinished(QModbusReply *reply, PlcPollBlock block);
// 发送单点 M/D 写请求;缓存等待后续轮询确认
/**
* @brief 发送单个 M 位写请求,缓存等待后续轮询确认
* @param address 要写入的 M 区地址
* @param value 要写入的位值
* @return 异步写请求提交结果
*/
RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value);
/**
* @brief 发送单个 D 字写请求,缓存等待后续轮询确认
* @param address 要写入的 D 区地址
* @param value 要写入的 16 位字值
* @return 异步写请求提交结果
*/
RegisterWriteResult sendWordWrite(
const RegisterAddress &address, std::int16_t value);
/**
* @brief 发送连续 D 字写请求,缓存等待后续轮询确认
* @param address 连续写入的起始 D 区地址
* @param values 按地址递增顺序写入的 16 位字值
* @return 异步写请求提交结果
*/
RegisterWriteResult sendWordsWrite(
const RegisterAddress &address,
const std::vector<std::int16_t> &values);
// 更新“所有轮询块均成功读取”的真机进入资格
/**
* @brief 更新所有轮询块均成功读取的真机进入资格
* @param completed 是否已经完成本轮首读要求
* @param force_notification 是否在状态未变化时仍强制发送通知
*/
void updateInitialReadCompleted(bool completed, bool force_notification = false);
// 处理没有主动断开时发生的串口断线
void handleUnexpectedDisconnect();
// 把 Qt Modbus 错误转换成项目自己的通信错误
/**
* @brief 把 Qt Modbus 错误转换成项目自己的通信错误
* @param error Qt Modbus 返回的设备错误
*/
void handleModbusError(QModbusDevice::Error error);
// 关闭串口并清理当前连接的请求和状态
void closeSerialSession();
// 修改连接状态并通知外部观察者
/**
* @brief 修改连接状态并通知外部观察者
* @param state 要切换到的新连接状态
*/
void setState(PlcConnectionState state);
// 保存错误信息、更新状态并通知外部观察者
/**
* @brief 保存错误信息、更新状态并通知外部观察者
* @param failure 已经分类的通信故障
*/
void setError(const PlcCommunicationFailure &failure);

PlcRegisterRepository &repository_; // 用于保存 PLC 最近一次成功读回的 M/D 值


+ 6
- 0
app/src/infrastructure/plc_discovery_service.cpp 查看文件

@@ -71,6 +71,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery(
{
available_ports.push_back(toUtf8(port.portName()));
}
// 用可用端口和受支持的串口参数生成按顺序尝试的候选列表
candidates_ = buildPlcDiscoveryCandidates(preferred, available_ports);
if (candidates_.empty())
{
@@ -168,6 +169,7 @@ void PlcDiscoveryService::tryCurrentCandidate()
static_cast<QSerialPort::StopBits>(candidate.stopBits));
master_->setTimeout(kDiscoveryResponseTimeoutMs);
master_->setNumberOfRetries(0);
// 异步打开当前候选串口,连接结果由状态变化回调继续处理
if (!master_->connectDevice())
{
QTimer::singleShot(
@@ -195,6 +197,7 @@ void PlcDiscoveryService::sendProbe()
const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_);
const QModbusDataUnit request(
QModbusDataUnit::HoldingRegisters, 0, 1);
// 异步读取 D0,只用回复判断当前参数能否与 PLC 通信
QModbusReply *reply = master_->sendReadRequest(
request, candidate.serverAddress);
if (reply == nullptr)
@@ -209,6 +212,7 @@ void PlcDiscoveryService::sendProbe()
this,
[this, reply, generation]
{
// 回复到达后校验搜索代次,避免旧候选结果干扰当前尝试
handleProbeFinished(reply, generation);
});
}
@@ -274,6 +278,7 @@ void PlcDiscoveryService::continueAfterDisconnect()
}
++current_candidate_;
phase_ = Phase::Idle;
// 当前候选无响应,释放串口后再异步尝试下一组参数
QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate);
}

@@ -303,6 +308,7 @@ void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state)
}
if (state == QModbusDevice::ConnectedState && phase_ == Phase::Connecting)
{
// 候选串口打开成功后读取 D0,确认链路上是否有 PLC 回复
sendProbe();
return;
}


+ 31
- 6
app/src/infrastructure/plc_discovery_service.h 查看文件

@@ -19,17 +19,29 @@ class QModbusRtuSerialMaster;
class PlcDiscoveryService final : public QObject, public PlcDiscoveryGateway
{
public:
/**
* @brief 创建独立的 PLC 自动搜索服务
* @param parent Qt 父对象
*/
explicit PlcDiscoveryService(QObject *parent = nullptr);
~PlcDiscoveryService() override;

// 枚举串口和候选参数,并异步逐项探测 PLC
/**
* @brief 枚举串口和候选参数,并异步逐项探测 PLC
* @param preferred 优先尝试的串口参数
* @return 搜索启动结果,最终搜索结果通过回调返回
*/
PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) override;
// 取消当前自动搜索并释放探测串口
void cancelDiscovery() override;
// 返回当前是否正在自动搜索
bool isDiscovering() const override;
// 设置搜索进度和最终结果回调
/**
* @brief 设置搜索进度和最终结果回调
* @param progress_changed 每次候选参数或进度变化时调用
* @param discovery_finished 搜索结束、取消或失败时调用
*/
void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override;
@@ -47,15 +59,28 @@ private:
void tryCurrentCandidate();
// 串口打开后发送只读 D0 探测请求
void sendProbe();
// 处理当前 D0 探测结果
/**
* @brief 处理当前 D0 探测结果
* @param reply 当前候选串口的异步 Modbus 回复
* @param attempt_generation 发起请求时的尝试代次,用于丢弃过期回复
*/
void handleProbeFinished(QModbusReply *reply, std::uint64_t attempt_generation);
// 释放当前串口,释放完成后进入下一个候选或报告结果
/**
* @brief 释放当前串口,释放完成后进入下一个候选或报告结果
* @param found 当前候选参数是否已经探测到 PLC
*/
void disconnectCurrent(bool found);
// 当前串口完全释放后完成状态迁移
void continueAfterDisconnect();
// 报告最终结果并清理搜索状态
/**
* @brief 报告最终结果并清理搜索状态
* @param outcome 要交给调用方的最终搜索结果
*/
void finishDiscovery(const PlcDiscoveryOutcome &outcome);
// 处理 Qt Modbus 主站连接状态变化
/**
* @brief 处理 Qt Modbus 主站连接状态变化
* @param state Qt Modbus 主站的新连接状态
*/
void handleDeviceStateChanged(QModbusDevice::State state);

std::unique_ptr<QModbusRtuSerialMaster> master_; // 独立的探测主站对象


+ 16
- 0
app/src/infrastructure/plc_register_repository.cpp 查看文件

@@ -4,6 +4,7 @@

namespace {

// 同时检查地址范围和寄存器区域是否符合操作要求
bool validAddress(const RegisterAddress &address, RegisterArea area)
{
return address.isValid() && address.area() == area;
@@ -11,8 +12,10 @@ bool validAddress(const RegisterAddress &address, RegisterArea area)

} // namespace

// 初始化空缓存,所有地址在首次读回前都保持不可用
PlcRegisterRepository::PlcRegisterRepository() = default;

// 读取 M 位缓存,未完成过成功读回的地址返回不可用
BitReadResult PlcRegisterRepository::readBit(const RegisterAddress &address) const
{
if (!address.isValid())
@@ -29,6 +32,7 @@ BitReadResult PlcRegisterRepository::readBit(const RegisterAddress &address) con
: BitReadResult{false, false, RegisterError::Unavailable};
}

// 转发 M 位异步写请求,等待后续 PLC 读回再更新缓存
RegisterWriteResult PlcRegisterRepository::writeBit(
const RegisterAddress &address, bool value)
{
@@ -37,10 +41,12 @@ RegisterWriteResult PlcRegisterRepository::writeBit(
return {false, address.isValid()
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
// 仓库不预改 M 缓存,只把请求交给通信服务异步写 PLC
return bit_handler_ ? bit_handler_(address, value)
: RegisterWriteResult{false, RegisterError::Unavailable};
}

// 读取 D 字缓存,未完成过成功读回的地址返回不可用
WordReadResult PlcRegisterRepository::readWord(const RegisterAddress &address) const
{
if (!address.isValid())
@@ -57,6 +63,7 @@ WordReadResult PlcRegisterRepository::readWord(const RegisterAddress &address) c
: WordReadResult{false, 0, RegisterError::Unavailable};
}

// 转发单个 D 字异步写请求,等待后续 PLC 读回再更新缓存
RegisterWriteResult PlcRegisterRepository::writeWord(
const RegisterAddress &address, std::int16_t value)
{
@@ -65,16 +72,19 @@ RegisterWriteResult PlcRegisterRepository::writeWord(
return {false, address.isValid()
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
// 仓库不预改单字缓存,只把请求交给通信服务异步写 PLC
return word_handler_ ? word_handler_(address, value)
: RegisterWriteResult{false, RegisterError::Unavailable};
}

// 复用仓库基类的连续读取逻辑,逐字保留缓存有效性检查
WordsReadResult PlcRegisterRepository::readWords(
const RegisterAddress &address, int count) const
{
return RegisterRepository::readWords(address, count);
}

// 校验连续范围后转发 D 字批量异步写请求
RegisterWriteResult PlcRegisterRepository::writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values)
@@ -89,14 +99,17 @@ RegisterWriteResult PlcRegisterRepository::writeWords(
}
if (count == 1)
{
// 单字写入统一走基础回调,避免通信层维护两条等价路径
return word_handler_ ? word_handler_(address, values.front())
: RegisterWriteResult{
false, RegisterError::Unavailable};
}
// 多字请求一次性交给通信服务,避免拆开 32 位或 64 位数值
return words_handler_ ? words_handler_(address, values)
: RegisterWriteResult{false, RegisterError::Unavailable};
}

// 保存通信服务提供的异步写入入口
void PlcRegisterRepository::setWriteHandlers(
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler,
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler,
@@ -109,6 +122,7 @@ void PlcRegisterRepository::setWriteHandlers(
words_handler_ = std::move(words_handler);
}

// 用 PLC 成功读回的值更新 M 位缓存和有效标记
void PlcRegisterRepository::updateBit(int address, bool value)
{
if (address < 0 || address > RegisterAddress::kMaximumIndex)
@@ -120,6 +134,7 @@ void PlcRegisterRepository::updateBit(int address, bool value)
valid_bits_[index] = true;
}

// 用 PLC 成功读回的值更新 D 字缓存和有效标记
void PlcRegisterRepository::updateWord(int address, std::int16_t value)
{
if (address < 0 || address > RegisterAddress::kMaximumIndex)
@@ -131,6 +146,7 @@ void PlcRegisterRepository::updateWord(int address, std::int16_t value)
valid_words_[index] = true;
}

// 清除当前通信会话的全部缓存有效标记
void PlcRegisterRepository::invalidate()
{
valid_bits_.fill(false);


+ 51
- 2
app/src/infrastructure/plc_register_repository.h 查看文件

@@ -12,26 +12,74 @@ class PlcRegisterRepository final : public RegisterRepository
public:
PlcRegisterRepository();

/**
* @brief 读取 PLC 最近一次成功读回的 M 位缓存
* @param address 要读取的 M 区地址
* @return 位读取结果,未成功读回过该地址时状态为不可用
*/
BitReadResult readBit(const RegisterAddress &address) const override;
/**
* @brief 将单个 M 位写请求转交给异步通信回调
* @param address 要写入的 M 区地址
* @param value 要写入的位值
* @return 写请求提交结果,成功不代表缓存已经更新
*/
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
/**
* @brief 读取 PLC 最近一次成功读回的 D 字缓存
* @param address 要读取的 D 区地址
* @return 字读取结果,未成功读回过该地址时状态为不可用
*/
WordReadResult readWord(const RegisterAddress &address) const override;
/**
* @brief 将单个 D 字写请求转交给异步通信回调
* @param address 要写入的 D 区地址
* @param value 要写入的 16 位字值
* @return 写请求提交结果,成功不代表缓存已经更新
*/
RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override;
/**
* @brief 连续读取多个已成功读回的 D 字缓存
* @param address 连续读取的起始 D 区地址
* @param count 要读取的连续字数量
* @return 连续字读取结果,任一地址不可用时返回失败状态
*/
WordsReadResult readWords(
const RegisterAddress &address, int count) const override;
/**
* @brief 将连续 D 字写请求转交给异步通信回调
* @param address 连续写入的起始 D 区地址
* @param values 按地址递增顺序写入的 16 位字值
* @return 写请求提交结果,成功不代表缓存已经更新
*/
RegisterWriteResult writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values) override;

// 注入异步写入回调;回调成功不代表缓存已经更新
/**
* @brief 注入 M 位、单个 D 字和连续 D 字的异步写入回调
* @param bit_handler M 位写入回调
* @param word_handler 单个 D 字写入回调
* @param words_handler 连续 D 字写入回调,可为空
*/
void setWriteHandlers(
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler,
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler,
std::function<RegisterWriteResult(
const RegisterAddress &,
const std::vector<std::int16_t> &)> words_handler = {});
// 由通信服务在读回成功后更新缓存并标记地址有效
/**
* @brief 由通信服务在读回成功后更新 M 位缓存并标记地址有效
* @param address M 区原始地址
* @param value PLC 读回的位值
*/
void updateBit(int address, bool value);
/**
* @brief 由通信服务在读回成功后更新 D 字缓存并标记地址有效
* @param address D 区原始地址
* @param value PLC 读回的 16 位字值
*/
void updateWord(int address, std::int16_t value);
// 通信会话失效时清除“已读”标记,但保留数组内的旧值用于诊断
void invalidate();
@@ -46,6 +94,7 @@ private:
std::array<bool, kRegisterCount> valid_words_{}; // 对应 D 地址是否读到过有效值
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler_; // M 区异步写入回调
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler_; // D 区异步写入回调
// D 区连续多字异步写入回调
std::function<RegisterWriteResult(
const RegisterAddress &, const std::vector<std::int16_t> &)> words_handler_;
};

+ 15
- 0
app/src/infrastructure/runtime_project_bundle.cpp 查看文件

@@ -17,15 +17,18 @@

namespace {

// 尾部固定保存 16 字节魔数、4 字节版本、8 字节长度和 32 字节哈希
constexpr int kMagicSize = 16;
constexpr int kFooterSize = kMagicSize + 4 + 8 + 32;
constexpr quint32 kFormatVersion = 1;
constexpr qint64 kCopyBufferSize = 64 * 1024;
const QByteArray kMagic = QByteArrayLiteral("QTPROX_RUNTIME_B");

// 编译期锁定魔数长度,避免修改文本后破坏尾部字段偏移
static_assert(sizeof("QTPROX_RUNTIME_B") - 1 == kMagicSize,
"runtime bundle magic must have a fixed length");

// 按固定小端格式向尾部追加 32 位无符号整数
void appendUint32LittleEndian(QByteArray *bytes, quint32 value)
{
for (int shift = 0; shift < 32; shift += 8)
@@ -34,6 +37,7 @@ void appendUint32LittleEndian(QByteArray *bytes, quint32 value)
}
}

// 按固定小端格式向尾部追加 64 位无符号整数
void appendUint64LittleEndian(QByteArray *bytes, quint64 value)
{
for (int shift = 0; shift < 64; shift += 8)
@@ -42,6 +46,7 @@ void appendUint64LittleEndian(QByteArray *bytes, quint64 value)
}
}

// 从尾部的小端字节序读取 32 位无符号整数
quint32 readUint32LittleEndian(const char *bytes)
{
quint32 value = 0;
@@ -54,6 +59,7 @@ quint32 readUint32LittleEndian(const char *bytes)
return value;
}

// 从尾部的小端字节序读取 64 位无符号整数
quint64 readUint64LittleEndian(const char *bytes)
{
quint64 value = 0;
@@ -66,6 +72,7 @@ quint64 readUint64LittleEndian(const char *bytes)
return value;
}

// 组装用于定位和校验工程数据的固定长度尾部
QByteArray makeFooter(quint64 project_size, const QByteArray &hash)
{
QByteArray footer;
@@ -77,6 +84,7 @@ QByteArray makeFooter(quint64 project_size, const QByteArray &hash)
return footer;
}

// 构造带错误原因的无效封装结果
RuntimeProjectBundleLoadResult invalidBundle(const QString &message)
{
return {RuntimeProjectBundleStatus::Invalid, {}, message};
@@ -84,6 +92,7 @@ RuntimeProjectBundleLoadResult invalidBundle(const QString &message)

} // namespace

// 将模板和工程数据写入临时文件,全部成功后再生成目标运行程序
RuntimeProjectBundleWriteResult RuntimeProjectBundleService::write(
const QString &template_executable,
const QString &project_file,
@@ -109,6 +118,7 @@ RuntimeProjectBundleWriteResult RuntimeProjectBundleService::write(

const QString temporary_executable = destination_executable
+ QStringLiteral(".runtime-bundle-tmp");
// 所有追加操作都落在临时文件,失败时不会留下可被误用的目标 exe
QFile::remove(temporary_executable);
if (!QFile::copy(template_executable, temporary_executable))
{
@@ -131,6 +141,7 @@ RuntimeProjectBundleWriteResult RuntimeProjectBundleService::write(
QCryptographicHash hash(QCryptographicHash::Sha256);
quint64 project_size = 0;
bool succeeded = true;
// 分块复制可限制大工程的内存占用,并同步计算封装内容的 SHA-256
while (!project.atEnd())
{
const QByteArray chunk = project.read(kCopyBufferSize);
@@ -168,6 +179,7 @@ RuntimeProjectBundleWriteResult RuntimeProjectBundleService::write(
return {true, {}};
}

// 从可执行文件尾部定位工程数据并依次校验格式、长度和哈希
RuntimeProjectBundleLoadResult RuntimeProjectBundleService::load(
const QString &executable_path)
{
@@ -197,6 +209,7 @@ RuntimeProjectBundleLoadResult RuntimeProjectBundleService::load(
return {RuntimeProjectBundleStatus::NotFound, {}, {}};
}

// 尾部字段使用固定偏移,读取结果不依赖当前 CPU 的字节序
const quint32 version = readUint32LittleEndian(footer.constData() + kMagicSize);
const quint64 project_size = readUint64LittleEndian(
footer.constData() + kMagicSize + 4);
@@ -204,6 +217,7 @@ RuntimeProjectBundleLoadResult RuntimeProjectBundleService::load(
{
return invalidBundle(QObject::tr("不支持的用户运行程序工程数据版本"));
}
// 先验证长度边界再转换为 qint64,避免损坏数据触发整数溢出
if (project_size > static_cast<quint64>(
executable_size - static_cast<qint64>(kFooterSize))
|| project_size > static_cast<quint64>(std::numeric_limits<qint64>::max()))
@@ -225,6 +239,7 @@ RuntimeProjectBundleLoadResult RuntimeProjectBundleService::load(
}

const QByteArray expected_hash = footer.right(32);
// 哈希不一致说明工程数据被截断或修改,不能交给工程解析器
if (QCryptographicHash::hash(project_data, QCryptographicHash::Sha256)
!= expected_hash)
{


+ 20
- 2
app/src/infrastructure/runtime_project_bundle.h 查看文件

@@ -14,23 +14,31 @@
/** 用户运行程序工程数据的读取状态 */
enum class RuntimeProjectBundleStatus
{
// 可执行文件中没有运行工程封装
NotFound,
// 工程数据已通过格式和哈希校验
Loaded,
// 找到了封装痕迹但内容无效
Invalid
};

/** 从用户运行程序中读取的工程数据 */
struct RuntimeProjectBundleLoadResult
{
// 本次读取的最终状态
RuntimeProjectBundleStatus status = RuntimeProjectBundleStatus::NotFound;
// 校验通过后的原始工程 JSON 数据
QByteArray project_data;
// 封装无效时供界面显示的错误信息
QString message;
};

/** 创建用户运行程序的结果 */
struct RuntimeProjectBundleWriteResult
{
// 是否成功生成带工程数据的运行程序
bool succeeded = false;
// 写入失败时供界面显示的错误信息
QString message;
};

@@ -38,13 +46,23 @@ struct RuntimeProjectBundleWriteResult
class RuntimeProjectBundleService final
{
public:
/** 将工程文件追加到运行程序模板末尾 */
/**
* @brief 复制运行程序模板,并将运行版工程 JSON 封装到目标 exe 末尾
* @param template_executable 已编译运行程序模板的完整路径,文件必须存在且可读
* @param project_file 要封装的运行版工程 JSON 完整路径,文件必须存在且可读
* @param destination_executable 最终生成的用户运行程序完整路径,目标文件不能已存在
* @return 封装结果,失败时 message 包含可直接显示的错误原因
*/
static RuntimeProjectBundleWriteResult write(
const QString &template_executable,
const QString &project_file,
const QString &destination_executable);

/** 从当前 exe 末尾读取工程数据并校验完整性 */
/**
* @brief 从指定 exe 末尾读取运行版工程 JSON,并校验封装版本、长度和哈希
* @param executable_path 要读取的用户运行程序完整路径,文件必须存在且可读
* @return 读取结果,区分未封装、加载成功和封装无效三种状态
*/
static RuntimeProjectBundleLoadResult load(
const QString &executable_path);
};

+ 18
- 0
app/src/infrastructure/runtime_settings_loader.cpp 查看文件

@@ -24,10 +24,13 @@ namespace {

struct ParsedEntry
{
// 去除首尾空白后的字段值
QString value;
// 字段在配置文件中的行号,用于给出明确错误位置
int line = 0;
};

// 按运行版支持的最小字段集合生成 UTF-8 INI 内容
QString defaultFileContents(const PlcSerialConfiguration &configuration)
{
return QStringLiteral(
@@ -46,6 +49,7 @@ QString defaultFileContents(const PlcSerialConfiguration &configuration)
.arg(configuration.stopBits);
}

// 返回运行版串口配置的代码默认值
PlcSerialConfiguration defaultConfiguration()
{
PlcSerialConfiguration configuration;
@@ -53,17 +57,20 @@ PlcSerialConfiguration defaultConfiguration()
return configuration;
}

// 将 Qt 字符串完整转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

// 将一条 UTF-8 诊断信息加入加载结果
void addMessage(RuntimeSettingsLoadResult *result, const QString &message)
{
result->messages.push_back(toUtf8(message));
}

// 构造整份配置失效并回退代码默认值的加载结果
RuntimeSettingsLoadResult failureResult(
const QString &file_path, const QString &reason)
{
@@ -80,6 +87,7 @@ RuntimeSettingsLoadResult failureResult(
return result;
}

// 判断整数是否属于业务允许的离散取值
bool containsValue(std::initializer_list<int> values, int value)
{
for (const int candidate : values)
@@ -92,6 +100,7 @@ bool containsValue(std::initializer_list<int> values, int value)
return false;
}

// 读取并检查一个整数配置,缺失时使用指定默认值
bool readInteger(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
@@ -128,6 +137,7 @@ bool readInteger(
return true;
}

// 为范围合法但不属于离散选项的字段追加错误信息
void addUnsupportedValueError(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
@@ -143,12 +153,14 @@ void addUnsupportedValueError(

} // namespace

// 返回当前运行程序同目录下固定的 runtime.ini 路径
QString RuntimeSettingsLoader::defaultFilePath()
{
return QDir(QCoreApplication::applicationDirPath()).filePath(
QStringLiteral("config/runtime.ini"));
}

// 加载运行版串口配置,文件缺失时先创建默认配置
RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
{
if (!QFileInfo::exists(file_path))
@@ -162,6 +174,7 @@ RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
.arg(QDir::toNativeSeparators(directory)));
}
PlcSerialConfiguration configuration = defaultConfiguration();
// QSaveFile 只有 commit 成功才替换目标文件,避免留下半份默认配置
QSaveFile file(file_path);
const QByteArray data = defaultFileContents(configuration).toUtf8();
if (!file.open(QIODevice::WriteOnly)
@@ -189,6 +202,7 @@ RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
{
return failureResult(file_path, file.errorString());
}
// ConverterState 用于严格识别非法 UTF-8,防止乱码字段被当成有效配置
QTextCodec::ConverterState converter_state;
QString text = QTextCodec::codecForName("UTF-8")->toUnicode(
data.constData(), data.size(), &converter_state);
@@ -212,6 +226,7 @@ RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
QStringList notices;
QStringList errors;
QString current_section;
// 手动解析可以同时识别重复字段、未知字段和准确行号
const QStringList lines = text.split(QLatin1Char('\n'));
for (int index = 0; index < lines.size(); ++index)
{
@@ -349,6 +364,7 @@ RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
}
if (!errors.isEmpty())
{
// 任一字段有错时整份回退,避免混用部分用户值和部分默认值
result.plcDefaults = defaultConfiguration();
result.warningRequired = true;
result.warningMessage = toUtf8(
@@ -374,6 +390,7 @@ RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
return result;
}

// 校验并原子写入运行版串口配置
bool RuntimeSettingsLoader::write(
const QString &file_path,
const PlcSerialConfiguration &configuration,
@@ -399,6 +416,7 @@ bool RuntimeSettingsLoader::write(
}
return false;
}
// QSaveFile 提交失败时保留原文件,运行版不会读到未写完的配置
QSaveFile file(file_path);
const QByteArray data = defaultFileContents(configuration).toUtf8();
if (!file.open(QIODevice::WriteOnly)


+ 16
- 2
app/src/infrastructure/runtime_settings_loader.h 查看文件

@@ -18,9 +18,13 @@
/** HMI 专用运行版的串口配置加载结果 */
struct RuntimeSettingsLoadResult
{
// 本次启动最终采用的 PLC 串口参数
PlcSerialConfiguration plcDefaults;
// 显示到底部输出面板的加载过程和错误信息
std::vector<std::string> messages;
// 是否需要向用户弹出一次配置警告
bool warningRequired = false;
// 配置失效时向用户显示的警告内容
std::string warningMessage;
};

@@ -30,9 +34,19 @@ class RuntimeSettingsLoader final
public:
/** 返回导出运行版目录下的串口配置路径 */
static QString defaultFilePath();
/** 读取只包含 PLC 串口字段的 UTF-8 INI */
/**
* @brief 读取只包含 PLC 串口字段的 UTF-8 INI
* @param file_path 运行版串口配置文件的完整路径
* @return 配置加载结果,包含实际采用的参数、过程消息和警告信息
*/
static RuntimeSettingsLoadResult load(const QString &file_path);
/** 以原子方式写入导出运行版所需的最小 INI */
/**
* @brief 以原子方式写入导出运行版所需的最小 INI
* @param file_path 目标配置文件的完整路径
* @param configuration 要写入的 PLC 串口参数
* @param error 可选的错误信息输出,失败时写入可直接显示的原因
* @return 写入成功返回 true,否则返回 false
*/
static bool write(
const QString &file_path,
const PlcSerialConfiguration &configuration,


+ 14
- 1
app/src/main.cpp 查看文件

@@ -35,7 +35,6 @@
#include "services/register_comment_service.h"
#include "ui/main_window.h"

// 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数
int main(int argc, char *argv[])
{
// Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏
@@ -47,9 +46,12 @@ int main(int argc, char *argv[])
// 设置组织名称,用来区分这个程序保存的 Qt 配置
application.setOrganizationName(QStringLiteral("QtProXinJe"));

// 检查当前可执行文件末尾是否封装了用户运行工程,以此区分编程器和用户运行版
const QFileInfo executable_info(application.applicationFilePath());
// 读取当前 EXE 尾部的封装工程,并校验封装版本、长度和哈希
const RuntimeProjectBundleLoadResult bundled_project_result =
RuntimeProjectBundleService::load(executable_info.absoluteFilePath());
// 检测到封装标记但内容损坏时直接退出,避免带着不完整工程继续运行
if (bundled_project_result.status == RuntimeProjectBundleStatus::Invalid)
{
QMessageBox::critical(
@@ -60,11 +62,14 @@ int main(int argc, char *argv[])
}
const bool bundled_runtime_project = bundled_project_result.status
== RuntimeProjectBundleStatus::Loaded;
// 当前只有成功加载封装工程的程序才进入隐藏编辑功能的用户运行模式
const bool user_runtime_mode = bundled_runtime_project;

ApplicationSettingsLoadResult application_settings;
// 用户运行版只读取随程序发布的串口配置,不读取编程器的常规应用配置
if (user_runtime_mode)
{
// 用户运行版从同目录 config/runtime.ini 读取自动连接 PLC 的串口参数
const RuntimeSettingsLoadResult runtime_settings =
RuntimeSettingsLoader::load(RuntimeSettingsLoader::defaultFilePath());
application_settings.settings.plcDefaults = runtime_settings.plcDefaults;
@@ -83,8 +88,10 @@ int main(int argc, char *argv[])
// 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象
ProjectService project_service(
project_storage, application_settings.settings.projectLimits);
// 工程存储接口按文件加载,因此先把可执行文件内的工程数据写入临时 JSON
if (user_runtime_mode)
{
// 进程号让同时启动的多个运行版使用不同临时文件,避免互相覆盖
const QString temporary_path = QDir::tempPath()
+ QStringLiteral("/qtproxinje-runtime-bundle-")
+ QString::number(application.applicationPid())
@@ -101,8 +108,10 @@ int main(int argc, char *argv[])
return 2;
}
temporary_file.close();
// 复用普通工程加载入口,将临时 JSON 校验后载入内存
const ProjectOperationResult result = project_service.load(
temporary_path.toUtf8().toStdString());
// 工程载入内存后立即删除临时文件,不在临时目录保留用户工程
QFile::remove(temporary_path);
if (!result.succeeded)
{
@@ -145,8 +154,10 @@ int main(int argc, char *argv[])
RegisterCommentService register_comment_service(project_service);
// 处理自由监控列表中的寄存器读取和单点写入
RegisterMonitorService register_monitor_service(active_register_repository);
// 编辑态离线写入同时记录为下次仿真的初始值
register_monitor_service.setOfflineInitialRepository(offline_initial_repository);
register_monitor_service.setOfflineInitialCaptureEnabled(true);
// 编辑态选择真机监控时直接使用 PLC 读回缓存,不切换运行态的数据入口
register_monitor_service.setEditingPlcRepository(plc_register_repository);
// 用虚拟寄存器执行本地梯形图,实现不连接 PLC 的离线仿真
OfflineSimulationService offline_simulation_service(
@@ -165,6 +176,7 @@ int main(int argc, char *argv[])
active_register_repository,
virtual_register_repository,
plc_register_repository);
// 用户运行版只运行 HMI 和 PLC 通信,不启动本地梯形图扫描
runtime_mode_service.setHmiOnlyRuntime(user_runtime_mode);

// 创建主窗口,并把界面操作需要的各项服务交给它使用
@@ -182,6 +194,7 @@ int main(int argc, char *argv[])
plc_discovery_service,
application_settings,
user_runtime_mode);
// 编程器立即显示主窗口,用户运行版等待自动连接和 PLC 首读完成后再显示
if (!user_runtime_mode)
{
main_window.show();


+ 9
- 0
app/src/services/alarm_editor_service.cpp 查看文件

@@ -7,6 +7,7 @@

namespace {

// 创建统一的报警编辑失败结果
AlarmEditorResult failure(AlarmEditorError error, const std::string &message)
{
return {false, error, message, {}};
@@ -14,16 +15,19 @@ AlarmEditorResult failure(AlarmEditorError error, const std::string &message)

} // namespace

// 保存工程服务引用,后续编辑都通过该服务修改当前工程
AlarmEditorService::AlarmEditorService(ProjectService &project_service)
: project_service_(project_service)
{
}

// 返回当前工程持有的报警定义列表
const std::vector<AlarmDefinition> &AlarmEditorService::definitions() const
{
return project_service_.project().alarmDefinitions;
}

// 按稳定 ID 查找报警定义,找不到时返回空指针
const AlarmDefinition *AlarmEditorService::findDefinition(
const std::string &id) const
{
@@ -34,6 +38,7 @@ const AlarmDefinition *AlarmEditorService::findDefinition(
return definition == definitions.cend() ? nullptr : &*definition;
}

// 校验数量和定义内容后添加一条带唯一 ID 的报警定义
AlarmEditorResult AlarmEditorService::addDefinition(
const AlarmDefinition &definition)
{
@@ -56,6 +61,7 @@ AlarmEditorResult AlarmEditorService::addDefinition(
return {true, AlarmEditorError::None, {}, candidate.id};
}

// 校验候选定义后整体替换指定 ID 的报警定义
AlarmEditorResult AlarmEditorService::updateDefinition(
const std::string &id, const AlarmDefinition &definition)
{
@@ -79,6 +85,7 @@ AlarmEditorResult AlarmEditorService::updateDefinition(
return {true, AlarmEditorError::None, {}, id};
}

// 删除指定 ID 的报警定义
AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id)
{
if (findDefinition(id) == nullptr)
@@ -86,6 +93,7 @@ AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id)
return failure(AlarmEditorError::NotFound, "未找到报警定义");
}
Project &project = project_service_.editProject();
// remove_if 先把目标项移到逻辑末尾,再由 erase 真正缩短容器
project.alarmDefinitions.erase(
std::remove_if(
project.alarmDefinitions.begin(), project.alarmDefinitions.end(),
@@ -97,6 +105,7 @@ AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id)
return {true, AlarmEditorError::None, {}, id};
}

// 从 alarm-1 开始递增查找当前工程内可用的 ID
std::string AlarmEditorService::makeUniqueId() const
{
int suffix = 1;


+ 10
- 9
app/src/services/alarm_editor_service.h 查看文件

@@ -10,19 +10,19 @@ class ProjectService;
// 报警编辑操作的失败分类
enum class AlarmEditorError
{
None,
NotFound,
DuplicateId,
InvalidDefinition
None, // 操作成功或没有错误
NotFound, // 目标报警定义不存在
DuplicateId, // 报警定义 ID 与现有定义重复
InvalidDefinition // 报警定义内容或数量不符合规则
};

// 报警编辑结果;成功时 id 是新增或更新后的定义 ID
struct AlarmEditorResult
{
bool succeeded = false;
AlarmEditorError error = AlarmEditorError::None;
std::string message;
std::string id;
bool succeeded = false; // 操作是否成功
AlarmEditorError error = AlarmEditorError::None; // 失败时的错误分类
std::string message; // 面向用户的失败原因
std::string id; // 新增、更新或删除的报警定义 ID
};

// 负责报警定义的增删改,不负责读取实时寄存器
@@ -44,7 +44,8 @@ public:
AlarmEditorResult removeDefinition(const std::string &id);

private:
// 生成当前工程内不重复的报警定义 ID
std::string makeUniqueId() const;

ProjectService &project_service_;
ProjectService &project_service_; // 非拥有的工程服务依赖
};

+ 6
- 0
app/src/services/alarm_service.cpp 查看文件

@@ -6,6 +6,7 @@

#include <algorithm>

// 保存工程服务和当前活动寄存器仓库的引用
AlarmService::AlarmService(
const ProjectService &project_service,
RegisterRepository &repository)
@@ -14,6 +15,7 @@ AlarmService::AlarmService(
{
}

// 根据最新寄存器值更新当前会话中的活动报警记录
void AlarmService::refresh()
{
// 每次刷新只根据当前读回值增删记录,不会把通信失败误判为报警解除
@@ -49,6 +51,7 @@ void AlarmService::refresh()
}
}

// 将指定活动报警记录标记为已确认
bool AlarmService::acknowledge(const std::string &definition_id)
{
const auto record = std::find_if(
@@ -65,16 +68,19 @@ bool AlarmService::acknowledge(const std::string &definition_id)
return true;
}

// 清空当前运行会话中的全部报警记录
void AlarmService::reset()
{
records_.clear();
}

// 返回按触发先后维护的只读报警记录列表
const std::vector<AlarmRecord> &AlarmService::records() const
{
return records_;
}

// 读取定义绑定的 M 位或 D 字并判断报警条件是否成立
std::optional<bool> AlarmService::evaluate(
const AlarmDefinition &definition) const
{


+ 8
- 7
app/src/services/application_settings.h 查看文件

@@ -9,9 +9,10 @@
// 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好
struct ApplicationSettings
{
int version = 1;
ProjectLimitSettings projectLimits;
HmiDefaultSettings hmiDefaults;
int version = 1; // 应用配置文件格式版本
ProjectLimitSettings projectLimits; // 当前工程采用的数量上限
HmiDefaultSettings hmiDefaults; // 新建 HMI 页面和控件的默认配置
// PLC 串口默认值沿用通信层配置,只把端口改为项目约定的 COM3
PlcSerialConfiguration plcDefaults = []
{
PlcSerialConfiguration configuration;
@@ -23,8 +24,8 @@ struct ApplicationSettings
// 配置加载结果同时携带主界面启动后需要显示的诊断信息
struct ApplicationSettingsLoadResult
{
ApplicationSettings settings;
std::vector<std::string> messages;
bool warningRequired = false;
std::string warningMessage;
ApplicationSettings settings; // 加载成功或回退后的最终应用配置
std::vector<std::string> messages; // 可写入日志的全部诊断信息
bool warningRequired = false; // 主界面启动后是否需要弹出警告
std::string warningMessage; // 需要向用户显示的警告内容
};

+ 37
- 4
app/src/services/hmi_editor_service.cpp 查看文件

@@ -14,6 +14,7 @@

namespace {

// 仅在调用方提供错误字符串时写入失败原因
void setError(std::string *error, const std::string &message)
{
if (error != nullptr)
@@ -22,11 +23,13 @@ void setError(std::string *error, const std::string &message)
}
}

// 创建统一的 HMI 编辑失败结果
HmiEditorResult failure(HmiEditorError error, const std::string &message)
{
return {false, error, message, {}};
}

// 返回控件绑定值连续占用的 D 字数量
int hmiBindingWordCount(const HmiControl &control)
{
if (control.binding.has_value() && control.binding->area() == RegisterArea::M)
@@ -44,6 +47,7 @@ int hmiBindingWordCount(const HmiControl &control)
return 1;
}

// 在指定寄存器区域内查找控件可用的首个连续地址
std::optional<RegisterAddress> nextHmiBindingAddress(
const Project &project,
const HmiControlDescriptor &descriptor,
@@ -89,6 +93,7 @@ std::optional<RegisterAddress> nextHmiBindingAddress(
return std::nullopt;
}

// 判断页面名称是否只包含空白字符
bool isBlank(const std::string &value)
{
return value.empty()
@@ -97,6 +102,7 @@ bool isBlank(const std::string &value)
[](unsigned char character) { return std::isspace(character) != 0; });
}

// 从 page-1 开始递增生成工程内唯一的页面 ID
std::string makeUniquePageId(const Project &project)
{
int suffix = 1;
@@ -114,6 +120,7 @@ std::string makeUniquePageId(const Project &project)
}
}

// 统计当前工程全部 HMI 页面中的控件总数
std::size_t totalControlCount(const Project &project)
{
std::size_t total = 0U;
@@ -126,6 +133,7 @@ std::size_t totalControlCount(const Project &project)

} // namespace

// 保存工程服务和 HMI 默认配置的引用
HmiEditorService::HmiEditorService(
ProjectService &project_service,
const HmiDefaultSettings &hmi_defaults)
@@ -134,18 +142,21 @@ HmiEditorService::HmiEditorService(
{
}

// 深拷贝当前页面集合和初始页面 ID 作为历史快照
HmiEditorService::HistoryState HmiEditorService::captureState() const
{
const Project &project = project_service_.project();
return {project.hmiPages, project.initialHmiPageId};
}

// 仅在编辑确实改变工程状态时记录撤销快照
void HmiEditorService::recordHistory(HistoryState before)
{
const HistoryState after = captureState();
history_.record(std::move(before), after, &HmiEditorService::statesEqual);
}

// 比较两个完整 HMI 编辑快照是否一致
bool HmiEditorService::statesEqual(
const HistoryState &left, const HistoryState &right)
{
@@ -164,6 +175,7 @@ bool HmiEditorService::statesEqual(
return true;
}

// 比较两个页面的基础属性和全部控件
bool HmiEditorService::pagesEqual(
const HmiPage &left, const HmiPage &right)
{
@@ -183,6 +195,7 @@ bool HmiEditorService::pagesEqual(
return true;
}

// 比较两个控件中所有会保存到工程的属性
bool HmiEditorService::controlsEqual(
const HmiControl &left, const HmiControl &right)
{
@@ -205,6 +218,7 @@ bool HmiEditorService::controlsEqual(
&& page_jump_equal;
}

// 创建撤销或重做不可执行时的统一失败结果
HmiEditorResult HmiEditorService::historyFailure(const std::string &message)
{
return {false, HmiEditorError::InvalidOperation, message, {}};
@@ -278,6 +292,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

// 新建并校验一个页面,成功后记录单步撤销历史
HmiEditorResult HmiEditorService::addPage(const std::string &name)
{
if (isBlank(name))
@@ -321,6 +336,7 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

// 校验新尺寸不会让已有控件越界后调整页面大小
HmiEditorResult HmiEditorService::resizePage(
const std::string &page_id, int width, int height)
{
@@ -355,6 +371,7 @@ HmiEditorResult HmiEditorService::resizePage(
return {true, HmiEditorError::None, {}, page_id};
}

// 校验名称非空且不重复后修改页面名称
HmiEditorResult HmiEditorService::renamePage(
const std::string &page_id, const std::string &name)
{
@@ -399,6 +416,7 @@ HmiEditorResult HmiEditorService::renamePage(
return {true, HmiEditorError::None, {}, page_id};
}

// 确认页面可删除且未被引用后从工程中移除页面
HmiEditorResult HmiEditorService::removePage(const std::string &page_id)
{
const Project &current = project_service_.project();
@@ -448,6 +466,7 @@ HmiEditorResult HmiEditorService::removePage(const std::string &page_id)
return {true, HmiEditorError::None, {}, page_id};
}

// 将指定页面在页面列表中上移或下移一位
HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offset)
{
if (offset != -1 && offset != 1)
@@ -479,6 +498,7 @@ HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offse
return {true, HmiEditorError::None, {}, page_id};
}

// 将指定页面设置为工程启动时显示的初始页面
HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id)
{
if (findPage(page_id) == nullptr)
@@ -495,6 +515,7 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id)
return {true, HmiEditorError::None, {}, page_id};
}

// 根据控件注册描述创建控件并分配默认寄存器地址
HmiEditorResult HmiEditorService::addControl(
const std::string &page_id, HmiControlType type)
{
@@ -549,12 +570,14 @@ HmiEditorResult HmiEditorService::addControl(
target_page->controls.back().id};
}

// 通过批量删除入口删除单个控件
HmiEditorResult HmiEditorService::removeControl(
const std::string &page_id, const std::string &control_id)
{
return removeControls(page_id, {control_id});
}

// 完整校验所选控件后一次性删除,避免留下部分修改
HmiEditorResult HmiEditorService::removeControls(
const std::string &page_id,
const std::vector<std::string> &control_ids)
@@ -602,7 +625,7 @@ HmiEditorResult HmiEditorService::removeControls(
return {true, HmiEditorError::None, {}, control_ids.front()};
}

// 移动 / 缩放控件
// 校验候选矩形后移动或缩放指定控件
HmiEditorResult HmiEditorService::moveControl(
const std::string &page_id,
const std::string &control_id,
@@ -650,6 +673,7 @@ HmiEditorResult HmiEditorService::moveControl(
return {true, HmiEditorError::None, {}, control_id};
}

// 按所选控件的整体范围批量计算并应用对齐位置
HmiEditorResult HmiEditorService::alignControls(
const std::string &page_id,
const std::vector<std::string> &control_ids,
@@ -794,7 +818,7 @@ HmiEditorResult HmiEditorService::alignControls(
return {true, HmiEditorError::None, {}, control_ids.front()};
}

// 更新控件全部属性
// 校验并整体更新控件全部可编辑属性
HmiEditorResult HmiEditorService::updateControl(
const std::string &page_id,
const std::string &control_id,
@@ -842,6 +866,7 @@ HmiEditorResult HmiEditorService::updateControl(
return {true, HmiEditorError::None, {}, control.id};
}

// 保留相对布局批量粘贴控件,任一控件失败时整体回滚
HmiEditorResult HmiEditorService::pasteControls(
const std::string &page_id,
const std::vector<HmiControl> &controls,
@@ -948,16 +973,19 @@ HmiEditorResult HmiEditorService::pasteControls(
return {true, HmiEditorError::None, {}, new_ids.front()};
}

// 判断当前 HMI 编辑会话是否有可撤销操作
bool HmiEditorService::canUndo() const
{
return history_.canUndo();
}

// 判断当前 HMI 编辑会话是否有可重做操作
bool HmiEditorService::canRedo() const
{
return history_.canRedo();
}

// 恢复最近一份编辑前快照
HmiEditorResult HmiEditorService::undo()
{
const std::optional<HistoryState> target = history_.undo(captureState());
@@ -971,6 +999,7 @@ HmiEditorResult HmiEditorService::undo()
return {true, HmiEditorError::None, {}, {}};
}

// 重新应用最近一份被撤销的快照
HmiEditorResult HmiEditorService::redo()
{
const std::optional<HistoryState> target = history_.redo(captureState());
@@ -984,11 +1013,13 @@ HmiEditorResult HmiEditorService::redo()
return {true, HmiEditorError::None, {}, {}};
}

// 清空当前 HMI 编辑会话的撤销和重做历史
void HmiEditorService::clearHistory()
{
history_.clear();
}

// 校验控件自身配置、页面边界和页面跳转目标
bool HmiEditorService::validateEditableControl(
const HmiPage &page, const HmiControl &control, std::string *error) const
{
@@ -1014,6 +1045,7 @@ bool HmiEditorService::validateEditableControl(
return true;
}

// 检查候选控件 ID 是否与页面内其他控件重复
bool HmiEditorService::hasDuplicateControlId(
const HmiPage &page,
const std::string &excluded_id,
@@ -1023,7 +1055,8 @@ bool HmiEditorService::hasDuplicateControlId(
page.controls.cbegin(), page.controls.cend(),
[&excluded_id, &candidate_id](const HmiControl &item)
{
return item.id != excluded_id && item.id == candidate_id; // 如果这个控件不是正在编辑的原控件,并且它的 ID 等于准备使用的新 ID
// 排除正在编辑的原控件,只检查同页的其他控件
return item.id != excluded_id && item.id == candidate_id;
});
}

@@ -1052,7 +1085,7 @@ HmiControl HmiEditorService::makeControl(
return control;
}

// 生成页面内不重复控件 ID,前缀 + 自增数字
// 生成由类型前缀和递增数字组成的页面内唯一控件 ID
std::string HmiEditorService::makeUniqueId(
const HmiPage &page, const std::string &prefix)
{


+ 9
- 8
app/src/services/hmi_editor_service.h 查看文件

@@ -52,12 +52,12 @@ struct HmiEditorResult
*/
enum class HmiAlignment
{
Left,
HorizontalCenter,
Right,
Top,
VerticalCenter,
Bottom
Left, // 左边缘对齐
HorizontalCenter, // 水平中心线对齐
Right, // 右边缘对齐
Top, // 上边缘对齐
VerticalCenter, // 垂直中心线对齐
Bottom // 下边缘对齐
};

/**
@@ -71,6 +71,7 @@ public:
/**
* @brief 创建 HMI 编辑服务
* @param project_service 用于读取和原子修改当前工程的项目服务
* @param hmi_defaults 新建页面和控件使用的默认配置
*/
explicit HmiEditorService(
ProjectService &project_service,
@@ -318,6 +319,6 @@ private:
* @brief 非拥有的工程服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
const HmiDefaultSettings &hmi_defaults_;
EditorHistory<HistoryState> history_;
const HmiDefaultSettings &hmi_defaults_; // 非拥有的 HMI 默认配置依赖
EditorHistory<HistoryState> history_; // 当前 HMI 编辑会话的撤销和重做历史
};

+ 5
- 0
app/src/services/hmi_navigation_service.cpp 查看文件

@@ -4,11 +4,13 @@

#include <algorithm>

// 保存工程服务引用并等待运行会话启动
HmiNavigationService::HmiNavigationService(ProjectService &project_service)
: project_service_(project_service)
{
}

// 从工程配置的初始页面开始新的 HMI 运行会话
HmiNavigationResult HmiNavigationService::start()
{
// 运行开始始终从工程声明的初始页面进入,而不是沿用上次会话页面
@@ -21,6 +23,7 @@ HmiNavigationResult HmiNavigationService::start()
return navigateTo(project.initialHmiPageId);
}

// 确认页面存在后切换当前运行页面
HmiNavigationResult HmiNavigationService::navigateTo(const std::string &page_id)
{
const Project &project = project_service_.project();
@@ -35,11 +38,13 @@ HmiNavigationResult HmiNavigationService::navigateTo(const std::string &page_id)
return {true, HmiNavigationError::None, {}, current_page_id_};
}

// 结束运行会话并清空当前页面 ID
void HmiNavigationService::stop()
{
current_page_id_.clear();
}

// 返回当前运行页面 ID,未启动时返回空字符串
const std::string &HmiNavigationService::currentPageId() const
{
return current_page_id_;


+ 9
- 9
app/src/services/hmi_navigation_service.h 查看文件

@@ -7,18 +7,18 @@ class ProjectService;
// HMI 页面跳转失败分类
enum class HmiNavigationError
{
None,
NoPages,
PageNotFound
None, // 跳转成功或没有错误
NoPages, // 当前工程没有 HMI 页面
PageNotFound // 指定的目标页面不存在
};

// 页面跳转结果;成功时 pageId 是当前页 ID
struct HmiNavigationResult
{
bool succeeded = false;
HmiNavigationError error = HmiNavigationError::None;
std::string message;
std::string pageId;
bool succeeded = false; // 页面跳转是否成功
HmiNavigationError error = HmiNavigationError::None; // 失败时的错误分类
std::string message; // 面向用户的失败原因
std::string pageId; // 跳转成功后的当前页面 ID
};

// 管理运行态当前页面,不直接操作 Qt 页面控件
@@ -37,6 +37,6 @@ public:
const std::string &currentPageId() const;

private:
ProjectService &project_service_;
std::string current_page_id_;
ProjectService &project_service_; // 非拥有的工程服务依赖
std::string current_page_id_; // 当前运行会话显示的页面 ID
};

+ 20
- 0
app/src/services/hmi_runtime_service.cpp 查看文件

@@ -9,18 +9,22 @@

namespace {

// 创建统一的 HMI 寄存器读取失败结果
HmiRuntimeReadResult readFailure(HmiRuntimeError error)
{
return {false, error, false, std::int16_t{0}};
}

// 创建统一的 HMI 寄存器写入失败结果
HmiRuntimeWriteResult writeFailure(HmiRuntimeError error)
{
return {false, error};
}

// 将类型化寄存器数值统一转换为按钮条件比较使用的 double
double numericValueAsDouble(const RegisterNumericValue &value)
{
// std::visit 根据 variant 当前保存的实际数值类型调用转换逻辑
return std::visit(
[](const auto &typed_value)
{
@@ -29,6 +33,7 @@ double numericValueAsDouble(const RegisterNumericValue &value)
value);
}

// 按按钮条件配置的比较符判断实际值是否满足期望值
bool compareButtonValue(
double actual, HmiButtonConditionOperator operation, double expected)
{
@@ -53,11 +58,13 @@ bool compareButtonValue(

} // namespace

// 保存当前运行模式提供的寄存器仓库引用
HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository)
: repository_(repository)
{
}

// 按控件运行值类型读取 M 位或连续 D 字并转换为统一结果
HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const
{
const HmiControlDescriptor *descriptor =
@@ -152,6 +159,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
}
}

// 读取状态文本绑定值并映射为对应的运行态显示文本
HmiStatusTextReadResult HmiRuntimeService::readStatusText(
const HmiControl &control) const
{
@@ -187,6 +195,7 @@ HmiStatusTextReadResult HmiRuntimeService::readStatusText(
ranges.cbegin(), ranges.cend(),
[number](const HmiStatusValueRange &range)
{
// D 状态区间统一使用左闭右开规则 [下限, 上限)
return (!range.lowerBound.has_value()
|| number >= *range.lowerBound)
&& (!range.upperBound.has_value()
@@ -199,6 +208,7 @@ HmiStatusTextReadResult HmiRuntimeService::readStatusText(
true, HmiRuntimeError::None, match->text};
}

// 读取并判断按钮配置的 M 位或 D 数值启用条件
HmiButtonEnabledResult HmiRuntimeService::evaluateButtonEnabled(
const HmiControl &control) const
{
@@ -267,6 +277,7 @@ HmiButtonEnabledResult HmiRuntimeService::evaluateButtonEnabled(
enabled ? HmiRuntimeError::None : HmiRuntimeError::ConditionNotMet};
}

// 根据按钮操作方式处理按下和释放事件并写入绑定的 M 位
HmiRuntimeWriteResult HmiRuntimeService::operateButton(
const HmiControl &control, HmiButtonEvent event)
{
@@ -283,6 +294,7 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton(
return writeFailure(HmiRuntimeError::InvalidBinding);
}

// 释放事件不重复校验条件,确保点动按钮即使条件变化也能复位
if (event == HmiButtonEvent::Pressed)
{
const HmiButtonEnabledResult enabled = evaluateButtonEnabled(control);
@@ -339,12 +351,14 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton(
}
}

// HMI 按钮只向活动仓库提交 M 写请求,真机模式再转发到 PLC
const RegisterWriteResult result = repository_.writeBit(*control.binding, value);
return result.succeeded
? HmiRuntimeWriteResult{true, HmiRuntimeError::None}
: writeFailure(repositoryError(result.error));
}

// 按控件数值类型编码并写入绑定的连续 D 字
HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput(
const HmiControl &control, double value)
{
@@ -370,6 +384,7 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput(
{
return writeFailure(HmiRuntimeError::InvalidBinding);
}
// 数值输入按完整字宽提交,真机模式由通信层一次写入连续 D
const RegisterWriteResult result = repository_.writeWords(
*control.binding, *encoded);
return result.succeeded
@@ -377,16 +392,21 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput(
: writeFailure(repositoryError(result.error));
}

// 直接转发指定 M 位的仓库读取结果
BitReadResult HmiRuntimeService::readBit(const RegisterAddress &address) const
{
// 真机模式读取活动 PLC 仓库中最近一次成功轮询的 M 缓存
return repository_.readBit(address);
}

// 直接转发指定 D 字的仓库读取结果
WordReadResult HmiRuntimeService::readWord(const RegisterAddress &address) const
{
// 真机模式读取活动 PLC 仓库中最近一次成功轮询的 D 缓存
return repository_.readWord(address);
}

// 将底层寄存器错误转换为 HMI 运行态错误
HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error)
{
// 领域仓库错误被收敛为 HMI 可理解的运行错误


+ 8
- 6
app/src/services/hmi_runtime_service.h 查看文件

@@ -50,16 +50,17 @@ struct HmiRuntimeWriteResult

struct HmiButtonEnabledResult
{
bool succeeded = false;
bool enabled = false;
HmiRuntimeError error = HmiRuntimeError::ConditionUnavailable;
bool succeeded = false; // 是否成功读取并评估启用条件
bool enabled = false; // 当前按钮是否允许操作
HmiRuntimeError error = HmiRuntimeError::ConditionUnavailable; // 不可用原因
};

// 状态文本控件读取和映射后的显示结果
struct HmiStatusTextReadResult
{
bool succeeded = false;
HmiRuntimeError error = HmiRuntimeError::None;
std::string text;
bool succeeded = false; // 是否成功读到寄存器并匹配显示文本
HmiRuntimeError error = HmiRuntimeError::None; // 失败时的错误分类
std::string text; // 读取成功后应显示的文本
};

/**
@@ -94,6 +95,7 @@ public:
* 控件未绑定、绑定区域错误或仓库不可用时不返回有效值
*/
HmiRuntimeReadResult readControl(const HmiControl &control) const;
// 读取状态文本绑定值并按 M 状态或 D 数值区间映射显示文本
HmiStatusTextReadResult readStatusText(const HmiControl &control) const;
/** @brief 读取并评估按钮启用条件;无条件按钮直接返回可用 */
HmiButtonEnabledResult evaluateButtonEnabled(


+ 19
- 0
app/src/services/logic_command_service.cpp 查看文件

@@ -10,6 +10,7 @@

namespace {

// 按空白字符把一行命令拆分为助记符和操作数
std::vector<std::string> splitTokens(const std::string &text)
{
std::istringstream stream(text);
@@ -22,6 +23,7 @@ std::vector<std::string> splitTokens(const std::string &text)
return tokens;
}

// 将助记符中的 ASCII 字母统一转换为大写
std::string upperAscii(std::string value)
{
std::transform(
@@ -33,11 +35,13 @@ std::string upperAscii(std::string value)
return value;
}

// 创建统一的命令解析失败结果
LogicCommandParseResult parseFailure(const std::string &message)
{
return {false, message, {}};
}

// 检查操作数数量并在不匹配时生成带示例的错误信息
bool requiresOperands(
const std::vector<std::string> &tokens,
std::size_t count,
@@ -54,6 +58,7 @@ bool requiresOperands(
return false;
}

// 解析并校验指令要求的 M 区或 D 区地址
bool parseAddress(
const std::string &text,
RegisterArea expected_area,
@@ -83,12 +88,14 @@ bool parseAddress(
return true;
}

// 声明 16 位常量解析函数,供字操作数解析提前调用
bool parseInt16(
const std::string &text,
const std::string &instruction,
std::int16_t *value,
LogicCommandParseResult *failure);

// 将操作数解析为 D 寄存器或 16 位常量
bool parseWordOperand(
const std::string &text,
const std::string &instruction,
@@ -129,6 +136,7 @@ bool parseWordOperand(
return true;
}

// 判断指令是否属于可放在网络起点或替换条件节点的加载类指令
bool isLoad(LogicCommandOpcode opcode)
{
return opcode == LogicCommandOpcode::Load
@@ -143,12 +151,14 @@ bool isLoad(LogicCommandOpcode opcode)
|| opcode == LogicCommandOpcode::CompareGreaterThanOrEqual;
}

// 判断指令是否属于并联条件指令
bool isOr(LogicCommandOpcode opcode)
{
return opcode == LogicCommandOpcode::Or
|| opcode == LogicCommandOpcode::OrInverse;
}

// 判断指令是否生成条件节点
bool isCondition(LogicCommandOpcode opcode)
{
return isLoad(opcode) || isOr(opcode)
@@ -156,11 +166,13 @@ bool isCondition(LogicCommandOpcode opcode)
|| opcode == LogicCommandOpcode::AndInverse;
}

// 判断当前编辑目标是否允许输入条件指令
bool isConditionTarget(LogicCommandTargetKind kind)
{
return kind != LogicCommandTargetKind::Output;
}

// 判断指令是否生成输出节点
bool isOutput(LogicCommandOpcode opcode)
{
return opcode == LogicCommandOpcode::Output
@@ -171,6 +183,7 @@ bool isOutput(LogicCommandOpcode opcode)
|| opcode == LogicCommandOpcode::Subtract;
}

// 将十进制文本严格解析为带符号 16 位常量
bool parseInt16(
const std::string &text,
const std::string &instruction,
@@ -180,6 +193,7 @@ bool parseInt16(
try
{
std::size_t consumed = 0;
// stol 配合 consumed 确保整段文本都是十进制整数而非部分匹配
const long parsed = std::stol(text, &consumed, 10);
if (consumed != text.size()
|| parsed < std::numeric_limits<std::int16_t>::min()
@@ -198,6 +212,7 @@ bool parseInt16(
}
}

// 创建统一的命令执行失败结果并保留指令类型
LogicCommandResult executionFailure(
const std::string &message, LogicCommandOpcode opcode)
{
@@ -206,11 +221,13 @@ LogicCommandResult executionFailure(

} // namespace

// 保存逻辑编辑服务引用,所有命令最终通过该服务提交
LogicCommandService::LogicCommandService(LogicEditorService &editor_service)
: editor_service_(editor_service)
{
}

// 解析助记符和操作数并生成对应的结构化节点配置
LogicCommandParseResult LogicCommandService::parse(const std::string &text)
{
std::vector<std::string> tokens = splitTokens(text);
@@ -389,6 +406,7 @@ LogicCommandParseResult LogicCommandService::parse(const std::string &text)
return parseFailure("当前指令暂不支持:" + mnemonic);
}

// 返回静态指令建议表,避免每次打开输入框重复构造
const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions()
{
static const std::vector<LogicCommandSuggestion> values{
@@ -415,6 +433,7 @@ const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions()
return values;
}

// 根据目标位置把结构化命令转换为原子的逻辑编辑操作
LogicCommandResult LogicCommandService::execute(
const LogicCommandRequest &request)
{


+ 57
- 52
app/src/services/logic_command_service.h 查看文件

@@ -7,87 +7,89 @@

enum class LogicCommandOpcode
{
Load,
LoadInverse,
LoadRising,
LoadFalling,
CompareEqual,
CompareNotEqual,
CompareLessThan,
CompareLessThanOrEqual,
CompareGreaterThan,
CompareGreaterThanOrEqual,
And,
AndInverse,
Or,
OrInverse,
Output,
Set,
Reset,
Move,
Add,
Subtract
Load, // 常开触点 LD
LoadInverse, // 常闭触点 LDI
LoadRising, // 上升沿触点 LDP
LoadFalling, // 下降沿触点 LDF
CompareEqual, // 等于比较 LD=
CompareNotEqual, // 不等于比较 LD<>
CompareLessThan, // 小于比较 LD<
CompareLessThanOrEqual, // 小于等于比较 LD<=
CompareGreaterThan, // 大于比较 LD>
CompareGreaterThanOrEqual, // 大于等于比较 LD>=
And, // 串联常开触点 AND
AndInverse, // 串联常闭触点 ANI
Or, // 并联常开触点 OR
OrInverse, // 并联常闭触点 ORI
Output, // 普通输出线圈 OUT
Set, // 置位线圈 SET
Reset, // 复位线圈 RST
Move, // 数据传送 MOV
Add, // 加法 ADD
Subtract // 减法 SUB
};

// 一条命令解析后的指令类型和节点配置
struct ParsedLogicCommand
{
LogicCommandOpcode opcode = LogicCommandOpcode::Load;
LogicCommandOpcode opcode = LogicCommandOpcode::Load; // 识别出的指令类型
// 与指令操作数对应的结构化逻辑节点配置
LogicNodeConfig config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen};
};

struct LogicCommandParseResult
{
bool succeeded = false;
std::string message;
ParsedLogicCommand command;
bool succeeded = false; // 命令文本是否解析成功
std::string message; // 面向用户的语法或参数错误
ParsedLogicCommand command; // 解析成功后的结构化命令
};

struct LogicCommandSuggestion
{
std::string mnemonic;
std::string description;
std::string operand_hint;
bool supported = true;
std::string mnemonic; // 指令助记符
std::string description; // 面向用户的指令用途
std::string operand_hint; // 操作数格式提示
bool supported = true; // 当前版本是否支持执行
};

enum class LogicCommandTargetKind
{
EmptyColumn,
BranchEmptyColumn,
WireColumn,
GapColumn,
Output,
ExistingNode
EmptyColumn, // 普通空白条件列
BranchEmptyColumn, // 并联分支中的空白条件列
WireColumn, // 已有水平连线的条件列
GapColumn, // 未连接的间隔列
Output, // 第 11 列输出区
ExistingNode // 已存在且准备替换的节点
};

struct LogicCommandTarget
{
LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn;
std::string rungId;
std::string expressionId;
int column = 0;
LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn; // 目标位置类型
std::string rungId; // 目标梯级 ID
std::string expressionId; // 替换场景中的现有节点 ID
int column = 0; // 目标条件列索引
};

struct LogicCommandRequest
{
std::string logicId;
std::string text;
LogicCommandTarget target;
bool continuing = false;
std::string currentRungId;
std::vector<std::string> parallelNodeIds;
std::string logicId; // 所属控制逻辑 ID
std::string text; // 用户输入的原始指令文本
LogicCommandTarget target; // 光标或现有节点对应的编辑目标
bool continuing = false; // 是否延续上一条命令的自动光标
std::string currentRungId; // 连续输入时当前所在梯级 ID
std::vector<std::string> parallelNodeIds; // OR/ORI 使用的连续条件节点 ID
};

struct LogicCommandResult
{
bool succeeded = false;
std::string message;
std::string id;
std::string rungId;
LogicCommandOpcode opcode = LogicCommandOpcode::Load;
LogicEditCursor nextCursor;
bool hasNextCursor = false;
bool succeeded = false; // 命令是否成功转换为编辑操作
std::string message; // 面向用户的解析或编辑失败原因
std::string id; // 新建或更新后的节点 ID
std::string rungId; // 编辑完成后节点所在的梯级 ID
LogicCommandOpcode opcode = LogicCommandOpcode::Load; // 本次执行的指令类型
LogicEditCursor nextCursor; // 连续输入建议移动到的下一个位置
bool hasNextCursor = false; // 是否提供有效的下一光标位置
};

// 将单条命令语解析并原子转换为现有结构化梯形图编辑操作
@@ -96,11 +98,14 @@ class LogicCommandService
public:
explicit LogicCommandService(LogicEditorService &editor_service);

// 将一行 PLC 指令文本解析为结构化节点配置
static LogicCommandParseResult parse(const std::string &text);
// 返回命令输入框可展示的全部指令建议
static const std::vector<LogicCommandSuggestion> &suggestions();

// 解析命令并通过逻辑编辑服务原子应用到指定目标
LogicCommandResult execute(const LogicCommandRequest &request);

private:
LogicEditorService &editor_service_;
LogicEditorService &editor_service_; // 非拥有的逻辑编辑服务依赖
};

+ 121
- 8
app/src/services/logic_editor_service.cpp 查看文件

@@ -13,6 +13,7 @@

namespace {

// 判断名称是否为空或只包含空白字符
bool isBlank(const std::string &value)
{
return value.empty()
@@ -24,12 +25,14 @@ bool isBlank(const std::string &value)
});
}

// 判断文本是否包含不允许写入单行网络注释的换行符
bool containsLineBreak(const std::string &value)
{
return value.find('\r') != std::string::npos
|| value.find('\n') != std::string::npos;
}

// 从 logic-1 开始递增生成工程内唯一的控制逻辑 ID
std::string makeUniqueLogicId(const Project &project)
{
for (std::size_t suffix = 1U;; ++suffix)
@@ -47,6 +50,7 @@ std::string makeUniqueLogicId(const Project &project)
}
}

// 统计工程全部控制逻辑中的梯级总数
std::size_t totalRungCount(const Project &project)
{
std::size_t count = 0U;
@@ -57,6 +61,7 @@ std::size_t totalRungCount(const Project &project)
return count;
}

// 按 ID 查找可编辑控制逻辑,工程为空或未找到时返回空指针
ControlLogic *editableLogic(Project *project, const std::string &logic_id)
{
if (project == nullptr)
@@ -69,6 +74,7 @@ ControlLogic *editableLogic(Project *project, const std::string &logic_id)
return found == project->controlLogics.end() ? nullptr : &*found;
}

// 按 ID 查找可编辑梯级,逻辑为空或未找到时返回空指针
LadderRung *editableRung(ControlLogic *logic, const std::string &rung_id)
{
if (logic == nullptr)
@@ -81,6 +87,7 @@ LadderRung *editableRung(ControlLogic *logic, const std::string &rung_id)
return found == logic->rungs.end() ? nullptr : &*found;
}

// 返回梯级在控制逻辑中的索引,未找到时返回梯级数量
std::size_t rungIndex(const ControlLogic &logic, const std::string &rung_id)
{
const auto found = std::find_if(
@@ -91,6 +98,7 @@ std::size_t rungIndex(const ControlLogic &logic, const std::string &rung_id)
: static_cast<std::size_t>(std::distance(logic.rungs.cbegin(), found));
}

// 判断垂直连接是否正好连接指定梯级和列边界
bool connectionMatches(
const VerticalConnection &connection,
const std::string &upper_rung_id,
@@ -104,13 +112,14 @@ bool connectionMatches(

struct SyntaxConnectivity
{
std::size_t rowCount = 0U;
std::vector<unsigned char> forwardBase;
std::vector<unsigned char> forward;
std::vector<unsigned char> backwardBase;
std::vector<unsigned char> backward;
std::vector<const VerticalConnection *> verticalByBoundaryAndUpperRow;
std::size_t rowCount = 0U; // 参与分析的梯级行数
std::vector<unsigned char> forwardBase; // 每行不考虑垂直线时从左侧可达的边界
std::vector<unsigned char> forward; // 合并垂直连通分量后的左侧可达边界
std::vector<unsigned char> backwardBase; // 每行不考虑垂直线时可到达输出的边界
std::vector<unsigned char> backward; // 合并垂直连通分量后的输出可达边界
std::vector<const VerticalConnection *> verticalByBoundaryAndUpperRow; // 按列边界和上端行索引的垂直线

// 将二维行号和列边界转换为状态数组的一维索引
std::size_t stateIndex(std::size_t row, int boundary) const
{
return row * static_cast<std::size_t>(
@@ -118,6 +127,7 @@ struct SyntaxConnectivity
+ static_cast<std::size_t>(boundary);
}

// 读取指定行和列边界的布尔连通状态
bool stateAt(
const std::vector<unsigned char> &states,
std::size_t row,
@@ -126,6 +136,7 @@ struct SyntaxConnectivity
return states[stateIndex(row, boundary)] != 0U;
}

// 返回指定列边界和上端行的垂直连接
const VerticalConnection *verticalAt(
int boundary, std::size_t upper_row) const
{
@@ -138,6 +149,7 @@ struct SyntaxConnectivity
}
};

// 分别从左母线和输出端分析每个网格边界的连通状态
SyntaxConnectivity analyzeConnectivity(const ControlLogic &logic)
{
SyntaxConnectivity analysis;
@@ -174,6 +186,7 @@ SyntaxConnectivity analyzeConnectivity(const ControlLogic &logic)
+ upper] = &connection;
}

// 同一列边界上由垂直线连成的多行共享可达状态
const auto mergeBoundaryComponents = [&analysis](
int boundary,
const std::vector<unsigned char> &base,
@@ -201,6 +214,7 @@ SyntaxConnectivity analyzeConnectivity(const ControlLogic &logic)
}
};

// 从左向右传播左母线可达状态
for (int boundary = 0;
boundary <= ProjectLimits::kMaximumConditionColumns;
++boundary)
@@ -219,6 +233,7 @@ SyntaxConnectivity analyzeConnectivity(const ControlLogic &logic)
boundary, analysis.forwardBase, &analysis.forward);
}

// 从输出端向左传播能够到达输出的状态
for (int boundary = ProjectLimits::kMaximumConditionColumns;
boundary >= 0;
--boundary)
@@ -242,6 +257,7 @@ SyntaxConnectivity analyzeConnectivity(const ControlLogic &logic)
return analysis;
}

// 按垂直连接关系为每个梯级计算从 0 开始的网络编号
std::vector<int> networkNumbersByRow(const ControlLogic &logic)
{
std::vector<int> networks(logic.rungs.size(), 0);
@@ -270,6 +286,7 @@ std::vector<int> networkNumbersByRow(const ControlLogic &logic)
return networks;
}

// 找出真正参与左右导通路径的垂直连接 ID
std::unordered_set<std::string> activeVerticalConnections(
const SyntaxConnectivity &analysis)
{
@@ -321,10 +338,11 @@ std::unordered_set<std::string> activeVerticalConnections(

struct LogicCleanupStats
{
std::size_t wireCells = 0U;
std::size_t verticalConnections = 0U;
std::size_t wireCells = 0U; // 被清除的无效水平线单元数
std::size_t verticalConnections = 0U; // 被清除的无效垂直连接数
};

// 删除不参与有效导通路径的水平线和垂直连接
LogicCleanupStats normalizeLogicWires(ControlLogic *logic)
{
LogicCleanupStats stats;
@@ -401,6 +419,7 @@ LogicCleanupStats normalizeLogicWires(ControlLogic *logic)
return stats;
}

// 返回控制逻辑中第一个未配置节点或断开的输出路径
std::optional<std::pair<LogicSyntaxLocation, std::string>> firstSyntaxIssue(
const ControlLogic &logic)
{
@@ -492,22 +511,26 @@ std::optional<std::pair<LogicSyntaxLocation, std::string>> firstSyntaxIssue(

} // namespace

// 保存工程服务引用,全部逻辑编辑通过该服务修改当前工程
LogicEditorService::LogicEditorService(ProjectService &project_service)
: project_service_(project_service)
{
}

// 深拷贝当前工程的全部控制逻辑作为历史快照
LogicEditorService::HistoryState LogicEditorService::captureState() const
{
return {project_service_.project().controlLogics};
}

// 仅在前后状态不同时记录一次撤销历史
void LogicEditorService::recordHistory(HistoryState before)
{
history_.record(
std::move(before), captureState(), &LogicEditorService::statesEqual);
}

// 在批量编辑失败时恢复逻辑快照和原有修改标记
void LogicEditorService::rollbackEdit(
HistoryState before, bool modified_before)
{
@@ -515,6 +538,7 @@ void LogicEditorService::rollbackEdit(
project_service_.restoreModifiedState(modified_before);
}

// 比较两个逻辑历史快照是否完全一致
bool LogicEditorService::statesEqual(
const HistoryState &left, const HistoryState &right)
{
@@ -532,6 +556,7 @@ bool LogicEditorService::statesEqual(
return true;
}

// 比较两个控制逻辑的名称、状态、梯级和垂直连接
bool LogicEditorService::logicsEqual(
const ControlLogic &left, const ControlLogic &right)
{
@@ -569,6 +594,7 @@ bool LogicEditorService::logicsEqual(
return true;
}

// 比较两个梯级的基础属性、条件单元和输出节点
bool LogicEditorService::rungsEqual(
const LadderRung &left, const LadderRung &right)
{
@@ -590,6 +616,7 @@ bool LogicEditorService::rungsEqual(
|| nodesEqual(*left.output, *right.output);
}

// 把网络续行注释合并到首行,并拒绝同一网络中的冲突注释
bool LogicEditorService::mergeNetworkComments(
ControlLogic *logic, std::string *error)
{
@@ -629,6 +656,7 @@ bool LogicEditorService::mergeNetworkComments(
return true;
}

// 比较两个梯形图单元的类型和可选节点
bool LogicEditorService::cellsEqual(
const LadderCell &left, const LadderCell &right)
{
@@ -637,6 +665,7 @@ bool LogicEditorService::cellsEqual(
&& (!left.node.has_value() || nodesEqual(*left.node, *right.node));
}

// 比较两个逻辑节点的 ID 和配置
bool LogicEditorService::nodesEqual(
const LogicNode &left, const LogicNode &right)
{
@@ -644,9 +673,11 @@ bool LogicEditorService::nodesEqual(
&& configsEqual(left.config, right.config);
}

// 按配置变体的实际类型比较全部字段
bool LogicEditorService::configsEqual(
const LogicNodeConfig &left, const LogicNodeConfig &right)
{
// std::visit 展开两个 variant,if constexpr 只编译实际类型适用的字段比较
return std::visit(
[](const auto &left_config, const auto &right_config)
{
@@ -700,12 +731,14 @@ bool LogicEditorService::configsEqual(
right);
}

// 创建撤销或重做不可执行时的统一失败结果
LogicEditorResult LogicEditorService::historyFailure(
const std::string &message)
{
return {false, LogicEditorError::InvalidOperation, message, {}};
}

// 按 ID 查找只读控制逻辑
const ControlLogic *LogicEditorService::findLogic(
const std::string &logic_id) const
{
@@ -716,6 +749,7 @@ const ControlLogic *LogicEditorService::findLogic(
return found == logics.cend() ? nullptr : &*found;
}

// 按控制逻辑和梯级 ID 查找只读梯级
const LadderRung *LogicEditorService::findRung(
const std::string &logic_id, const std::string &rung_id) const
{
@@ -730,6 +764,7 @@ const LadderRung *LogicEditorService::findRung(
return found == logic->rungs.cend() ? nullptr : &*found;
}

// 沿相邻垂直连接向上查找指定梯级所属网络的首行
const LadderRung *LogicEditorService::findNetworkHeadRung(
const std::string &logic_id, const std::string &rung_id) const
{
@@ -743,6 +778,7 @@ const LadderRung *LogicEditorService::findNetworkHeadRung(
return head == logic->rungs.size() ? nullptr : &logic->rungs[head];
}

// 按梯级和条件列查找只读网格单元
const LadderCell *LogicEditorService::findCell(
const std::string &logic_id,
const std::string &rung_id,
@@ -755,6 +791,7 @@ const LadderCell *LogicEditorService::findCell(
: &rung->cells[static_cast<std::size_t>(column)];
}

// 按单元 ID 在指定梯级中查找只读网格单元
const LadderCell *LogicEditorService::findCell(
const std::string &logic_id,
const std::string &rung_id,
@@ -764,6 +801,7 @@ const LadderCell *LogicEditorService::findCell(
return rung == nullptr ? nullptr : findLadderCell(*rung, cell_id);
}

// 按 ID 查找只读垂直连接
const VerticalConnection *LogicEditorService::findConnection(
const std::string &logic_id,
const std::string &connection_id) const
@@ -773,6 +811,7 @@ const VerticalConnection *LogicEditorService::findConnection(
? nullptr : findVerticalConnection(*logic, connection_id);
}

// 在全部梯级的条件区和输出区中按 ID 查找节点
const LogicNode *LogicEditorService::findNode(
const std::string &logic_id, const std::string &node_id) const
{
@@ -798,12 +837,14 @@ const LogicNode *LogicEditorService::findNode(
return nullptr;
}

// 返回工程中第一个控制逻辑 ID
std::string LogicEditorService::firstLogicId() const
{
const auto &logics = project_service_.project().controlLogics;
return logics.empty() ? std::string{} : logics.front().id;
}

// 返回指定控制逻辑中的第一个梯级 ID
std::string LogicEditorService::firstRungId(
const std::string &logic_id) const
{
@@ -812,6 +853,7 @@ std::string LogicEditorService::firstRungId(
? std::string{} : logic->rungs.front().id;
}

// 查找指定节点当前所在的梯级 ID
std::string LogicEditorService::rungIdForNode(
const std::string &logic_id, const std::string &node_id) const
{
@@ -837,6 +879,7 @@ std::string LogicEditorService::rungIdForNode(
return {};
}

// 返回工程为指定寄存器保存的地址备注
std::string LogicEditorService::registerCommentFor(
const RegisterAddress &address) const
{
@@ -845,6 +888,7 @@ std::string LogicEditorService::registerCommentFor(
return comment == nullptr ? std::string{} : comment->text;
}

// 在工程为空时创建默认控制逻辑和首个梯级
LogicEditorResult LogicEditorService::ensureDefaultLogic()
{
if (!project_service_.project().controlLogics.empty())
@@ -859,6 +903,7 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic()
return {true, LogicEditorError::None, {}, project.controlLogics.back().id};
}

// 校验名称和数量上限后添加控制逻辑
LogicEditorResult LogicEditorService::addLogic(const std::string &name)
{
if (isBlank(name) || name.size() > ProjectLimits::kMaximumTextBytes)
@@ -891,6 +936,7 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name)
return {true, LogicEditorError::None, {}, project.controlLogics.back().id};
}

// 校验名称非空且唯一后修改控制逻辑名称
LogicEditorResult LogicEditorService::renameLogic(
const std::string &logic_id, const std::string &name)
{
@@ -926,6 +972,7 @@ LogicEditorResult LogicEditorService::renameLogic(
return {true, LogicEditorError::None, {}, logic_id};
}

// 保证至少保留一个控制逻辑后删除指定逻辑
LogicEditorResult LogicEditorService::removeLogic(
const std::string &logic_id)
{
@@ -954,6 +1001,7 @@ LogicEditorResult LogicEditorService::removeLogic(
return {true, LogicEditorError::None, {}, logic_id};
}

// 将指定控制逻辑在工程列表中上移或下移一位
LogicEditorResult LogicEditorService::moveLogic(
const std::string &logic_id, int offset)
{
@@ -990,6 +1038,7 @@ LogicEditorResult LogicEditorService::moveLogic(
return {true, LogicEditorError::None, {}, logic_id};
}

// 修改控制逻辑启用状态并记录撤销历史
LogicEditorResult LogicEditorService::setLogicEnabled(
const std::string &logic_id, bool enabled)
{
@@ -1009,6 +1058,7 @@ LogicEditorResult LogicEditorService::setLogicEnabled(
return {true, LogicEditorError::None, {}, logic_id};
}

// 规整并检查指定控制逻辑的连接和节点配置
LogicSyntaxCheckResult LogicEditorService::checkSyntax(
const std::string &logic_id)
{
@@ -1021,6 +1071,7 @@ LogicSyntaxCheckResult LogicEditorService::checkSyntax(
return checkSyntaxForLogics({logic_id});
}

// 单独检查指定控制逻辑是否存在重复 M 线圈输出
LogicSyntaxCheckResult LogicEditorService::checkDoubleCoils(
const std::string &logic_id) const
{
@@ -1075,6 +1126,7 @@ LogicSyntaxCheckResult LogicEditorService::checkDoubleCoils(
return result;
}

// 规整并检查工程中全部已启用控制逻辑
LogicSyntaxCheckResult LogicEditorService::checkEnabledSyntax()
{
std::vector<std::string> logic_ids;
@@ -1088,6 +1140,7 @@ LogicSyntaxCheckResult LogicEditorService::checkEnabledSyntax()
return checkSyntaxForLogics(logic_ids);
}

// 以一个原子操作规整并检查给定的控制逻辑集合
LogicSyntaxCheckResult LogicEditorService::checkSyntaxForLogics(
const std::vector<std::string> &logic_ids)
{
@@ -1163,11 +1216,14 @@ LogicSyntaxCheckResult LogicEditorService::checkSyntaxForLogics(
return result;
}

// 在指定控制逻辑末尾添加一个空梯级
LogicEditorResult LogicEditorService::addRung(const std::string &logic_id)
{
// 末尾新建行复用统一插入入口,不指定参考行并选择向后插入
return insertRung(logic_id, {}, true);
}

// 在参考梯级之前或之后插入空梯级
LogicEditorResult LogicEditorService::insertRung(
const std::string &logic_id,
const std::string &reference_rung_id,
@@ -1202,6 +1258,7 @@ LogicEditorResult LogicEditorService::insertRung(
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
ControlLogic *editable = editableLogic(&project, logic_id);
// 在计算出的视觉位置创建空行,并同步维护跨过该位置的竖线
const std::string new_id = insertEmptyRungAt(editable, position);
std::string error;
if (!editable->validateStructure(project_service_.projectLimits(), &error))
@@ -1213,12 +1270,14 @@ LogicEditorResult LogicEditorService::insertRung(
return {true, LogicEditorError::None, {}, new_id};
}

// 通过批量删除入口删除单个梯级
LogicEditorResult LogicEditorService::removeRung(
const std::string &logic_id, const std::string &rung_id)
{
return removeRungs(logic_id, {rung_id});
}

// 完整校验目标后一次删除多个梯级并维护垂直连接
LogicEditorResult LogicEditorService::removeRungs(
const std::string &logic_id,
const std::vector<std::string> &rung_ids)
@@ -1269,6 +1328,7 @@ LogicEditorResult LogicEditorService::removeRungs(
return {true, LogicEditorError::None, {}, rung_ids.front()};
}

// 将注释统一写入指定梯级所属网络的首行
LogicEditorResult LogicEditorService::updateNetworkComment(
const std::string &logic_id,
const std::string &rung_id,
@@ -1298,16 +1358,19 @@ LogicEditorResult LogicEditorService::updateNetworkComment(
return {true, LogicEditorError::None, {}, head_rung_id};
}

// 在光标位置添加条件节点并移动到下一个可编辑位置
LogicEditResult LogicEditorService::applyConditionAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig &config,
bool configured)
{
// 统一由单格编辑事务校验节点、写入 Node 并计算下一光标
return applyCellAndAdvance(
logic_id, cursor, &config, configured);
}

// 在光标位置添加水平线并移动到下一个可编辑位置
LogicEditResult LogicEditorService::applyWireAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor)
@@ -1315,6 +1378,7 @@ LogicEditResult LogicEditorService::applyWireAndAdvance(
return applyCellAndAdvance(logic_id, cursor, nullptr, false);
}

// 设置同一梯级一段连续条件单元的水平线状态
LogicEditorResult LogicEditorService::setHorizontalWireRange(
const std::string &logic_id,
const std::string &rung_id,
@@ -1336,9 +1400,11 @@ LogicEditorResult LogicEditorService::setHorizontalWireRange(
{
cells.emplace_back(rung_id, column);
}
// 将连续列转换成明确网格列表后一次性提交横线状态
return setWireCells(logic_id, cells, connected);
}

// 原子设置多个网格单元的水平线状态
LogicEditorResult LogicEditorService::setWireCells(
const std::string &logic_id,
const std::vector<std::pair<std::string, int>> &cells,
@@ -1411,6 +1477,7 @@ LogicEditorResult LogicEditorService::setWireCells(
return {true, LogicEditorError::None, {}, cells.front().first};
}

// 原子清空多个条件单元并移除其中的节点或连线
LogicEditorResult LogicEditorService::clearCells(
const std::string &logic_id,
const std::vector<std::pair<std::string, int>> &cells)
@@ -1444,6 +1511,7 @@ LogicEditorResult LogicEditorService::clearCells(
return {true, LogicEditorError::None, {}, cells.front().first};
}

// 设置相邻梯级在指定列边界上的一条垂直连接
LogicEditorResult LogicEditorService::setVerticalConnection(
const std::string &logic_id,
const std::string &upper_rung_id,
@@ -1472,6 +1540,7 @@ LogicEditorResult LogicEditorService::setVerticalConnection(
connected);
}

// 添加一段垂直连接并返回继续向下画线的位置
LogicVerticalEditResult LogicEditorService::applyVerticalConnectionAndAdvance(
const std::string &logic_id,
const std::string &upper_rung_id,
@@ -1550,6 +1619,7 @@ LogicVerticalEditResult LogicEditorService::applyVerticalConnectionAndAdvance(
!already_connected};
}

// 设置两个梯级之间整段连续垂直连接的状态
LogicEditorResult LogicEditorService::setVerticalConnectionRange(
const std::string &logic_id,
const std::string &first_rung_id,
@@ -1625,6 +1695,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange(
});
if (connected && found == editable->verticalConnections.end())
{
// 每对相邻行保存一段竖线,跨多行的长竖线由多段组成
editable->verticalConnections.push_back({
makeUniqueId(*editable, "vertical"),
upper_id,
@@ -1651,6 +1722,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange(
return {true, LogicEditorError::None, {}, first_rung_id};
}

// 完整校验目标后一次删除多条垂直连接
LogicEditorResult LogicEditorService::removeVerticalConnections(
const std::string &logic_id,
const std::vector<std::string> &connection_ids)
@@ -1691,6 +1763,7 @@ LogicEditorResult LogicEditorService::removeVerticalConnections(
return {true, LogicEditorError::None, {}, connection_ids.front()};
}

// 原子删除选择中的条件单元、输出节点和垂直连接
LogicEditorResult LogicEditorService::deleteSelection(
const std::string &logic_id,
const LogicSelectionDeleteRequest &selection)
@@ -1788,6 +1861,7 @@ LogicEditorResult LogicEditorService::deleteSelection(
return {true, LogicEditorError::None, {}, logic_id};
}

// 在连续条件节点下方插入一条新的并联分支
LogicEditorResult LogicEditorService::addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,
@@ -1880,6 +1954,7 @@ LogicEditorResult LogicEditorService::addParallelBranch(
return {true, LogicEditorError::None, {}, node_id};
}

// 在当前网络的完整条件路径下方添加并联条件
LogicEditorResult LogicEditorService::addParallelToWholeCondition(
const std::string &logic_id,
const std::string &rung_id,
@@ -1909,6 +1984,7 @@ LogicEditorResult LogicEditorService::addParallelToWholeCondition(
logic_id, rung_id, node_ids, config, configured);
}

// 校验节点类型和网络位置后设置梯级输出
LogicEditorResult LogicEditorService::setOutput(
const std::string &logic_id,
const std::string &rung_id,
@@ -1974,6 +2050,7 @@ LogicEditorResult LogicEditorService::setOutput(
return {true, LogicEditorError::None, {}, node_id};
}

// 设置当前网络输出并返回下一条命令的建议光标
LogicEditResult LogicEditorService::applyOutputAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
@@ -2087,6 +2164,7 @@ LogicEditResult LogicEditorService::applyOutputAndAdvance(
cell.node.reset();
}
const std::string node_id = makeUniqueId(*logic, nodePrefix(config));
// 输出节点独立写入第 11 列输出槽,不占用前面的条件格
rung->output = LogicNode{node_id, config, configured};

std::string next_rung_id;
@@ -2109,6 +2187,7 @@ LogicEditResult LogicEditorService::applyOutputAndAdvance(
{next_rung_id, 0, false}};
}

// 在保持条件或输出类别不变的前提下更新节点配置
LogicEditorResult LogicEditorService::updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
@@ -2158,6 +2237,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig(
return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点");
}

// 校验选择并按普通对象或整行模式生成剪贴板片段
LogicClipboardCopyResult LogicEditorService::copySelection(
const std::string &logic_id,
const LogicSelectionCopyRequest &selection) const
@@ -2171,6 +2251,7 @@ LogicClipboardCopyResult LogicEditorService::copySelection(
const bool has_grid_objects = !selection.cells.empty()
|| !selection.outputRungIds.empty()
|| !selection.verticalConnectionIds.empty();
// 整行模式要求视觉行连续,并保留行内 Gap、输出和内部垂直线
if (!selection.wholeRungIds.empty())
{
if (has_grid_objects)
@@ -2244,6 +2325,7 @@ LogicClipboardCopyResult LogicEditorService::copySelection(

LogicClipboardFragment fragment;
fragment.mode = LogicClipboardMode::GridObjects;
// 先计算所有选中对象的外接范围,再统一转换成相对坐标
std::size_t minimum_row = logic->rungs.size();
std::size_t maximum_row = 0U;
int minimum_column = ProjectLimits::kMaximumLadderColumns;
@@ -2348,6 +2430,7 @@ LogicClipboardCopyResult LogicEditorService::copySelection(
}
fragment.rowSpan = static_cast<int>(maximum_row - minimum_row + 1U);
fragment.columnSpan = maximum_column - minimum_column + 1;
// 固定片段内部顺序,保证粘贴和测试结果不受选择先后影响
std::sort(
fragment.cells.begin(), fragment.cells.end(),
[](const LogicClipboardCell &left, const LogicClipboardCell &right)
@@ -2376,6 +2459,7 @@ LogicClipboardCopyResult LogicEditorService::copySelection(
return {{true, LogicEditorError::None, {}, {}}, std::move(fragment)};
}

// 将剪贴板片段原子粘贴到目标位置并返回新对象选择
LogicClipboardPasteResult LogicEditorService::pasteClipboard(
const std::string &logic_id,
const LogicClipboardFragment &fragment,
@@ -2389,6 +2473,7 @@ LogicClipboardPasteResult LogicEditorService::pasteClipboard(

if (fragment.mode == LogicClipboardMode::WholeRows)
{
// 写入工程前完整验证行结构、数量边界和内部垂直连接
if (fragment.rows.empty() || !fragment.cells.empty()
|| !fragment.outputs.empty())
{
@@ -2467,6 +2552,7 @@ LogicClipboardPasteResult LogicEditorService::pasteClipboard(
}
}

// 整批写入共享一个历史快照,后续任一校验失败都会整体回滚
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
@@ -2586,6 +2672,7 @@ LogicClipboardPasteResult LogicEditorService::pasteClipboard(
"粘贴片段超出当前梯形图行列范围"), {}, {}};
}

// 先验证所有目标单元,确认没有越界、重复或覆盖条件节点
std::unordered_set<std::string> destination_cells;
for (const LogicClipboardCell &source : fragment.cells)
{
@@ -2623,6 +2710,7 @@ LogicClipboardPasteResult LogicEditorService::pasteClipboard(
}
}

// 只有单独粘贴一个输出时允许明确替换已有输出
const bool explicit_output_replace = only_output
&& fragment.outputs.size() == 1U && fragment.rowSpan == 1;
std::unordered_set<std::size_t> destination_outputs;
@@ -2673,6 +2761,7 @@ LogicClipboardPasteResult LogicEditorService::pasteClipboard(
}
}

// 所有目标验证完成后才开始写入,保证失败时不留下部分对象
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
@@ -2767,6 +2856,7 @@ LogicClipboardPasteResult LogicEditorService::pasteClipboard(
return result;
}

// 从指定列开始连续粘贴一组条件节点
LogicEditorResult LogicEditorService::pasteConditionNodes(
const std::string &logic_id,
const std::string &rung_id,
@@ -2849,6 +2939,7 @@ LogicEditorResult LogicEditorService::pasteConditionNodes(
return {true, LogicEditorError::None, {}, first_id};
}

// 判断节点集合是否位于同一梯级且占用连续条件列
bool LogicEditorService::areConditionNodesContiguous(
const std::string &logic_id,
const std::string &rung_id,
@@ -2879,6 +2970,7 @@ bool LogicEditorService::areConditionNodesContiguous(
== static_cast<int>(columns.size());
}

// 原子删除给定 ID 对应的条件或输出节点
LogicEditorResult LogicEditorService::removeNodes(
const std::string &logic_id,
const std::vector<std::string> &node_ids)
@@ -2923,16 +3015,19 @@ LogicEditorResult LogicEditorService::removeNodes(
return {true, LogicEditorError::None, {}, node_ids.front()};
}

// 判断当前逻辑编辑会话是否有可撤销操作
bool LogicEditorService::canUndo() const
{
return history_.canUndo();
}

// 判断当前逻辑编辑会话是否有可重做操作
bool LogicEditorService::canRedo() const
{
return history_.canRedo();
}

// 恢复最近一次编辑前的完整控制逻辑快照
LogicEditorResult LogicEditorService::undo()
{
const std::optional<HistoryState> target = history_.undo(captureState());
@@ -2944,6 +3039,7 @@ LogicEditorResult LogicEditorService::undo()
return {true, LogicEditorError::None, {}, {}};
}

// 重新应用最近一次被撤销的控制逻辑快照
LogicEditorResult LogicEditorService::redo()
{
const std::optional<HistoryState> target = history_.redo(captureState());
@@ -2955,11 +3051,13 @@ LogicEditorResult LogicEditorService::redo()
return {true, LogicEditorError::None, {}, {}};
}

// 清空当前逻辑编辑会话的撤销和重做历史
void LogicEditorService::clearHistory()
{
history_.clear();
}

// 在一个条件单元应用节点或连线并计算下一光标位置
LogicEditResult LogicEditorService::applyCellAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
@@ -3034,6 +3132,7 @@ LogicEditResult LogicEditorService::applyCellAndAdvance(
ControlLogic *logic = editableLogic(&project, logic_id);
if (create_first_rung)
{
// 空逻辑第一次放置条件时先创建包含 10 个 Gap 的首行
target_rung_id = insertEmptyRungAt(logic, 0U);
}
LadderCell &cell = editableRung(logic, target_rung_id)
@@ -3047,6 +3146,7 @@ LogicEditResult LogicEditorService::applyCellAndAdvance(
else
{
edited_id = makeUniqueId(*logic, nodePrefix(*config));
// 条件配置写入目标格后,该格由 Gap 或 Wire 变成 Node
cell.kind = LadderCellKind::Node;
cell.node = LogicNode{edited_id, *config, configured};
}
@@ -3069,6 +3169,7 @@ LogicEditResult LogicEditorService::applyCellAndAdvance(
std::move(next)};
}

// 判断节点配置是否属于条件区支持的类型
bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config)
{
return std::holds_alternative<ContactNodeConfig>(config)
@@ -3076,6 +3177,7 @@ bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config)
|| std::holds_alternative<CompareNodeConfig>(config);
}

// 判断节点配置是否属于输出区支持的类型
bool LogicEditorService::isOutputConfig(const LogicNodeConfig &config)
{
return std::holds_alternative<CoilNodeConfig>(config)
@@ -3083,6 +3185,7 @@ bool LogicEditorService::isOutputConfig(const LogicNodeConfig &config)
|| std::holds_alternative<ArithmeticNodeConfig>(config);
}

// 返回节点配置类型对应的稳定 ID 前缀
std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config)
{
return std::visit(
@@ -3117,9 +3220,11 @@ std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config)
config);
}

// 从指定前缀的 1 号开始递增生成逻辑内唯一节点 ID
std::string LogicEditorService::makeUniqueId(
const ControlLogic &logic, const std::string &prefix)
{
// ID 需要同时避开逻辑、梯级、单元、节点和垂直连接的全部命名空间
const auto exists = [&logic](const std::string &candidate)
{
if (logic.id == candidate)
@@ -3160,6 +3265,7 @@ std::string LogicEditorService::makeUniqueId(
}
}

// 创建具有固定条件列数和稳定 ID 的空梯级
LadderRung LogicEditorService::makeEmptyRung(
const ControlLogic &logic, std::size_t visual_index)
{
@@ -3180,6 +3286,7 @@ LadderRung LogicEditorService::makeEmptyRung(
return rung;
}

// 在指定位置插入空梯级并拆分跨过该位置的垂直连接
std::string LogicEditorService::insertEmptyRungAt(
ControlLogic *logic, std::size_t position)
{
@@ -3194,6 +3301,7 @@ std::string LogicEditorService::insertEmptyRungAt(
std::vector<VerticalConnection> bridges;
if (!upper_id.empty() && !lower_id.empty())
{
// 原先跨过插入位置的每条竖线拆成上下两段
for (const VerticalConnection &connection : logic->verticalConnections)
{
if (connection.upperRungId == upper_id
@@ -3213,6 +3321,7 @@ std::string LogicEditorService::insertEmptyRungAt(
}),
logic->verticalConnections.end());
}
// 创建带稳定 ID 的新行,并初始化固定 10 个 Gap 条件格
LadderRung rung = makeEmptyRung(*logic, position);
const std::string new_id = rung.id;
logic->rungs.insert(
@@ -3235,6 +3344,7 @@ std::string LogicEditorService::insertEmptyRungAt(
return new_id;
}

// 删除指定位置的梯级并合并两侧能够连续的垂直连接
void LogicEditorService::removeRungAt(
ControlLogic *logic, std::size_t position)
{
@@ -3276,6 +3386,7 @@ void LogicEditorService::removeRungAt(
logic->rungs.begin() + static_cast<std::ptrdiff_t>(position));
if (!upper_id.empty() && !lower_id.empty())
{
// 只有上下两段都存在的列边界才能合并为跨过删除位置的新竖线
for (const auto &upper : upper_connections)
{
if (lower_connections.count(upper.first) != 0U)
@@ -3291,6 +3402,7 @@ void LogicEditorService::removeRungAt(
refreshRungNames(logic);
}

// 按当前显示顺序刷新全部梯级名称
void LogicEditorService::refreshRungNames(ControlLogic *logic)
{
if (logic == nullptr)
@@ -3303,6 +3415,7 @@ void LogicEditorService::refreshRungNames(ControlLogic *logic)
}
}

// 创建统一的逻辑编辑失败结果
LogicEditorResult LogicEditorService::failure(
LogicEditorError error, const std::string &message)
{


+ 163
- 77
app/src/services/logic_editor_service.h 查看文件

@@ -11,149 +11,165 @@ class ProjectService;

enum class LogicEditorError
{
None,
LogicNotFound,
RungNotFound,
CellNotFound,
ConnectionNotFound,
NodeNotFound,
InvalidNode,
InvalidOperation,
UnsupportedNodeChange,
DuplicateName,
LastLogicRequired
None, // 操作成功或没有错误
LogicNotFound, // 目标控制逻辑不存在
RungNotFound, // 目标梯级不存在
CellNotFound, // 目标网格单元不存在
ConnectionNotFound, // 目标垂直连接不存在
NodeNotFound, // 目标逻辑节点不存在
InvalidNode, // 节点配置或放置位置无效
InvalidOperation, // 编辑参数或当前状态不允许操作
UnsupportedNodeChange, // 不允许把现有节点改成目标类型
DuplicateName, // 控制逻辑名称与现有逻辑重复
LastLogicRequired // 删除后不能少于一个控制逻辑
};

// 逻辑编辑操作的统一结果
struct LogicEditorResult
{
bool succeeded = false;
LogicEditorError error = LogicEditorError::None;
std::string message;
std::string id;
bool succeeded = false; // 编辑操作是否成功
LogicEditorError error = LogicEditorError::None; // 失败时的错误分类
std::string message; // 面向用户的失败原因
std::string id; // 成功时返回相关逻辑、梯级或节点 ID
};

// 一次批量删除所包含的网格对象
struct LogicSelectionDeleteRequest
{
std::vector<std::pair<std::string, int>> cells;
std::vector<std::string> outputRungIds;
std::vector<std::string> verticalConnectionIds;
std::vector<std::pair<std::string, int>> cells; // 梯级 ID 和条件列组成的单元位置
std::vector<std::string> outputRungIds; // 需要清除输出节点的梯级 ID
std::vector<std::string> verticalConnectionIds; // 需要删除的垂直连接 ID
};

// 剪贴板片段保存普通对象或完整梯级行
enum class LogicClipboardMode
{
GridObjects,
WholeRows
GridObjects, // 只保存明确选中的网格对象
WholeRows // 保存完整行、空白单元和网络注释
};

// 剪贴板中的一个条件区单元
struct LogicClipboardCell
{
int relativeRow = 0;
int relativeColumn = 0;
LadderCellKind kind = LadderCellKind::Gap;
std::optional<LogicNode> node;
int relativeRow = 0; // 相对复制区域首行的行偏移
int relativeColumn = 0; // 相对复制区域首列的列偏移
LadderCellKind kind = LadderCellKind::Gap; // 单元保存的空白、连线或节点类型
std::optional<LogicNode> node; // 节点单元保存的完整节点配置
};

// 剪贴板中的一个输出节点
struct LogicClipboardOutput
{
int relativeRow = 0;
int relativeColumn = 0;
LogicNode node;
int relativeRow = 0; // 相对复制区域首行的行偏移
int relativeColumn = 0; // 相对复制区域首列的列偏移
LogicNode node; // 输出节点的完整配置
};

// 剪贴板中的一条垂直连接
struct LogicClipboardVerticalConnection
{
int upperRelativeRow = 0;
int relativeColumnBoundary = 0;
int upperRelativeRow = 0; // 垂直连接上端相对首行的行偏移
int relativeColumnBoundary = 0; // 相对复制区域首列的列边界
};

// 整行复制模式保存的一条梯级
struct LogicClipboardRow
{
std::string comment;
std::vector<LadderCell> cells;
std::optional<LogicNode> output;
std::string comment; // 梯级所属网络的注释
std::vector<LadderCell> cells; // 条件区全部单元
std::optional<LogicNode> output; // 第 11 列的可选输出节点
};

// 普通片段只保存选中的对象,整行片段才保存 Gap 和网络注释
struct LogicClipboardFragment
{
LogicClipboardMode mode = LogicClipboardMode::GridObjects;
std::vector<LogicClipboardCell> cells;
std::vector<LogicClipboardOutput> outputs;
std::vector<LogicClipboardVerticalConnection> verticalConnections;
std::vector<LogicClipboardRow> rows;
int rowSpan = 0;
int columnSpan = 0;
LogicClipboardMode mode = LogicClipboardMode::GridObjects; // 当前片段的复制模式
std::vector<LogicClipboardCell> cells; // 普通对象模式中的条件区单元
std::vector<LogicClipboardOutput> outputs; // 普通对象模式中的输出节点
std::vector<LogicClipboardVerticalConnection> verticalConnections; // 垂直连接集合
std::vector<LogicClipboardRow> rows; // 整行模式中的完整梯级
int rowSpan = 0; // 片段覆盖的总行数
int columnSpan = 0; // 片段覆盖的总列数
};

// 一次复制操作所选择的对象和完整梯级
struct LogicSelectionCopyRequest
{
std::vector<std::pair<std::string, int>> cells;
std::vector<std::string> outputRungIds;
std::vector<std::string> verticalConnectionIds;
std::vector<std::string> wholeRungIds;
std::vector<std::pair<std::string, int>> cells; // 选中的条件区单元位置
std::vector<std::string> outputRungIds; // 选中的输出节点所在梯级 ID
std::vector<std::string> verticalConnectionIds; // 选中的垂直连接 ID
std::vector<std::string> wholeRungIds; // 整行复制的梯级 ID
};

// 复制操作结果及生成的剪贴板片段
struct LogicClipboardCopyResult
{
LogicEditorResult copy;
LogicClipboardFragment fragment;
LogicEditorResult copy; // 复制请求的校验结果
LogicClipboardFragment fragment; // 校验成功后生成的剪贴板内容
};

// 剪贴板内容要粘贴到的网格位置
struct LogicPasteTarget
{
std::string rungId;
int column = 0;
bool output = false;
bool boundary = false;
std::string rungId; // 目标梯级 ID
int column = 0; // 目标条件列或输出列索引
bool output = false; // 目标是否位于输出区
bool boundary = false; // 目标是否位于列边界
};

// 粘贴操作结果和粘贴后应选中的对象
struct LogicClipboardPasteResult
{
LogicEditorResult edit;
LogicSelectionDeleteRequest selection;
std::vector<std::string> wholeRungIds;
LogicEditorResult edit; // 粘贴编辑结果
LogicSelectionDeleteRequest selection; // 普通对象模式下新对象的选择信息
std::vector<std::string> wholeRungIds; // 整行模式下新梯级 ID
};

// 连续编辑时的梯级和列位置
struct LogicEditCursor
{
std::string rungId;
int column = 0;
bool output = false;
std::string rungId; // 光标所在梯级 ID
int column = 0; // 光标所在条件列或输出列
bool output = false; // 光标是否处于输出区
};

// 编辑结果及下一次连续输入位置
struct LogicEditResult
{
LogicEditorResult edit;
LogicEditCursor nextCursor;
LogicEditorResult edit; // 本次编辑结果
LogicEditCursor nextCursor; // 编辑成功后的下一光标位置
};

// 垂直画线结果及下一次连续画线位置
struct LogicVerticalEditResult
{
LogicEditorResult edit;
std::string nextRungId;
int columnBoundary = -1;
bool changed = false;
LogicEditorResult edit; // 本次垂直连接编辑结果
std::string nextRungId; // 下一段垂直连接的上端梯级 ID
int columnBoundary = -1; // 下一段连接继续使用的列边界
bool changed = false; // 本次是否实际修改了连接
};

// 语法错误在逻辑网格中的定位信息
struct LogicSyntaxLocation
{
std::string logicId;
std::string rungId;
int network = 0;
int row = 0;
int column = 0;
std::string logicId; // 所属控制逻辑 ID
std::string rungId; // 所属梯级 ID
int network = 0; // 从 1 开始的网络编号
int row = 0; // 从 1 开始的显示行号
int column = 0; // 从 1 开始的显示列号
};

// 语法规整和检查的统计及错误结果
struct LogicSyntaxCheckResult
{
bool completed = false;
bool valid = false;
bool changed = false;
std::size_t checkedLogicCount = 0U;
std::size_t removedWireCells = 0U;
std::size_t removedVerticalConnections = 0U;
std::string message;
std::optional<LogicSyntaxLocation> location;
bool completed = false; // 是否完成全部目标逻辑的检查
bool valid = false; // 检查完成后逻辑是否有效
bool changed = false; // 规整过程是否修改了工程
std::size_t checkedLogicCount = 0U; // 已检查的控制逻辑数量
std::size_t removedWireCells = 0U; // 规整时删除的无效水平线单元数
std::size_t removedVerticalConnections = 0U; // 规整时删除的无效垂直连接数
std::string message; // 检查结论或首个错误说明
std::optional<LogicSyntaxLocation> location; // 首个语法错误的可选网格位置
};

// 连续网格的所有修改都经此服务原子提交,并进入同一份撤销历史
@@ -162,36 +178,53 @@ class LogicEditorService
public:
explicit LogicEditorService(ProjectService &project_service);

// 按 ID 查找只读控制逻辑,找不到时返回空指针
const ControlLogic *findLogic(const std::string &logic_id) const;
// 按逻辑和梯级 ID 查找只读梯级
const LadderRung *findRung(
const std::string &logic_id, const std::string &rung_id) const;
// 返回指定梯级所属网络的首个梯级
const LadderRung *findNetworkHeadRung(
const std::string &logic_id, const std::string &rung_id) const;
// 按梯级和条件列查找只读网格单元
const LadderCell *findCell(
const std::string &logic_id,
const std::string &rung_id,
int column) const;
// 按单元 ID 查找只读网格单元
const LadderCell *findCell(
const std::string &logic_id,
const std::string &rung_id,
const std::string &cell_id) const;
// 按 ID 查找只读垂直连接
const VerticalConnection *findConnection(
const std::string &logic_id,
const std::string &connection_id) const;
// 按 ID 查找条件或输出节点
const LogicNode *findNode(
const std::string &logic_id, const std::string &node_id) const;
// 返回工程中第一个控制逻辑 ID
std::string firstLogicId() const;
// 返回指定控制逻辑中的第一个梯级 ID
std::string firstRungId(const std::string &logic_id) const;
// 返回指定节点当前所在的梯级 ID
std::string rungIdForNode(
const std::string &logic_id, const std::string &node_id) const;
// 返回工程中为指定寄存器配置的地址备注
std::string registerCommentFor(const RegisterAddress &address) const;

// 在工程没有控制逻辑时创建默认逻辑和首个梯级
LogicEditorResult ensureDefaultLogic();
// 新建一个名称唯一的控制逻辑
LogicEditorResult addLogic(const std::string &name);
// 修改指定控制逻辑的名称
LogicEditorResult renameLogic(
const std::string &logic_id, const std::string &name);
// 删除指定控制逻辑但至少保留一个
LogicEditorResult removeLogic(const std::string &logic_id);
// 将控制逻辑在工程列表中上移或下移一位
LogicEditorResult moveLogic(const std::string &logic_id, int offset);
// 启用或停用指定控制逻辑
LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled);

/** 规整并检查指定控制逻辑,规整改动作为一次撤销操作 */
@@ -201,159 +234,212 @@ public:
/** 运行前规整并检查工程中全部已启用控制逻辑 */
LogicSyntaxCheckResult checkEnabledSyntax();

// 在指定控制逻辑末尾添加空梯级
LogicEditorResult addRung(const std::string &logic_id);
// 在参考梯级之前或之后插入空梯级
LogicEditorResult insertRung(
const std::string &logic_id,
const std::string &reference_rung_id,
bool after);
// 删除指定梯级
LogicEditorResult removeRung(
const std::string &logic_id, const std::string &rung_id);
// 完整校验后一次删除多个梯级
LogicEditorResult removeRungs(
const std::string &logic_id,
const std::vector<std::string> &rung_ids);
// 修改指定网络首行保存的网络注释
LogicEditorResult updateNetworkComment(
const std::string &logic_id,
const std::string &rung_id,
const std::string &comment);

// 在光标位置添加条件节点并返回下一编辑位置
LogicEditResult applyConditionAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig &config,
bool configured = false);
// 设置同一梯级内一段连续水平线的连接状态
LogicEditorResult setHorizontalWireRange(
const std::string &logic_id,
const std::string &rung_id,
int first_column,
int last_column,
bool connected);
// 在光标位置添加水平线并返回下一编辑位置
LogicEditResult applyWireAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor);
// 一次设置多个条件单元的水平线状态
LogicEditorResult setWireCells(
const std::string &logic_id,
const std::vector<std::pair<std::string, int>> &cells,
bool connected);
// 清空多个条件区单元中的节点或水平线
LogicEditorResult clearCells(
const std::string &logic_id,
const std::vector<std::pair<std::string, int>> &cells);
// 设置相邻梯级在指定列边界上的垂直连接
LogicEditorResult setVerticalConnection(
const std::string &logic_id,
const std::string &upper_rung_id,
const std::string &lower_rung_id,
int column_boundary,
bool connected);
// 从指定梯级添加一段垂直连接并返回下一段位置
LogicVerticalEditResult applyVerticalConnectionAndAdvance(
const std::string &logic_id,
const std::string &upper_rung_id,
int column_boundary);
// 设置一段连续梯级范围内的垂直连接状态
LogicEditorResult setVerticalConnectionRange(
const std::string &logic_id,
const std::string &first_rung_id,
const std::string &last_rung_id,
int column_boundary,
bool connected);
// 一次删除多条垂直连接
LogicEditorResult removeVerticalConnections(
const std::string &logic_id,
const std::vector<std::string> &connection_ids);
// 原子删除选择中的单元、输出节点和垂直连接
LogicEditorResult deleteSelection(
const std::string &logic_id,
const LogicSelectionDeleteRequest &selection);

// 为一组连续条件节点添加新的并联分支
LogicEditorResult addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &selected_node_ids,
const LogicNodeConfig &config,
bool configured = false);
// 在当前网络的全部条件下方添加并联条件
LogicEditorResult addParallelToWholeCondition(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config,
bool configured = false);
// 设置指定梯级第 11 列的输出节点
LogicEditorResult setOutput(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config,
bool configured = false);
// 在光标所在网络设置输出并返回下一编辑位置
LogicEditResult applyOutputAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig &config,
bool configured = false);
// 在不改变节点类别的前提下更新节点配置
LogicEditorResult updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config);
// 校验当前选择并生成可复用的逻辑剪贴板片段
LogicClipboardCopyResult copySelection(
const std::string &logic_id,
const LogicSelectionCopyRequest &selection) const;
// 将剪贴板片段原子粘贴到指定网格位置
LogicClipboardPasteResult pasteClipboard(
const std::string &logic_id,
const LogicClipboardFragment &fragment,
const LogicPasteTarget &target);
// 从指定列开始粘贴一组连续条件节点
LogicEditorResult pasteConditionNodes(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<LogicNode> &nodes,
int start_column = -1);
// 判断给定节点是否位于同一梯级的连续条件单元中
bool areConditionNodesContiguous(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &node_ids) const;
// 一次删除多个条件或输出节点
LogicEditorResult removeNodes(
const std::string &logic_id,
const std::vector<std::string> &node_ids);
// 判断当前逻辑编辑会话是否有可撤销操作
bool canUndo() const;
// 判断当前逻辑编辑会话是否有可重做操作
bool canRedo() const;
// 撤销最近一次成功的逻辑编辑
LogicEditorResult undo();
// 重做最近一次被撤销的逻辑编辑
LogicEditorResult redo();
// 清空逻辑编辑会话的撤销和重做历史
void clearHistory();

private:
// 撤销和重做使用的完整控制逻辑快照
struct HistoryState
{
std::vector<ControlLogic> logics;
std::vector<ControlLogic> logics; // 工程控制逻辑集合的深拷贝
};

// 捕获当前工程控制逻辑作为历史快照
HistoryState captureState() const;
// 仅在编辑实际改变逻辑时记录撤销快照
void recordHistory(HistoryState before);
// 恢复编辑前快照和原有工程修改标记
void rollbackEdit(HistoryState before, bool modified_before);
// 比较两个完整逻辑历史快照
static bool statesEqual(
const HistoryState &left, const HistoryState &right);
// 比较两个控制逻辑的全部可保存属性
static bool logicsEqual(
const ControlLogic &left, const ControlLogic &right);
// 比较两个梯级的单元、输出和网络属性
static bool rungsEqual(
const LadderRung &left, const LadderRung &right);
// 合并网络续行上的注释并检查同一网络的注释冲突
static bool mergeNetworkComments(
ControlLogic *logic, std::string *error);
// 比较两个梯形图单元及其节点
static bool cellsEqual(
const LadderCell &left, const LadderCell &right);
// 比较两个逻辑节点的 ID 和配置
static bool nodesEqual(const LogicNode &left, const LogicNode &right);
// 比较两个节点配置变体的全部字段
static bool configsEqual(
const LogicNodeConfig &left, const LogicNodeConfig &right);
// 创建撤销或重做不可执行时的统一失败结果
static LogicEditorResult historyFailure(const std::string &message);
// 判断配置是否属于条件节点
static bool isConditionConfig(const LogicNodeConfig &config);
// 判断配置是否属于输出节点
static bool isOutputConfig(const LogicNodeConfig &config);
// 返回节点配置对应的稳定 ID 前缀
static std::string nodePrefix(const LogicNodeConfig &config);
// 在控制逻辑内生成带指定前缀的唯一节点 ID
static std::string makeUniqueId(
const ControlLogic &logic, const std::string &prefix);
// 创建具有固定条件列数的空梯级
static LadderRung makeEmptyRung(
const ControlLogic &logic, std::size_t visual_index);
// 在指定位置插入空梯级并维护相关垂直连接
static std::string insertEmptyRungAt(
ControlLogic *logic, std::size_t position);
// 删除指定位置的梯级并重新连接可连续的垂直线
static void removeRungAt(ControlLogic *logic, std::size_t position);
// 按当前显示顺序刷新全部梯级名称
static void refreshRungNames(ControlLogic *logic);
// 创建统一的逻辑编辑失败结果
static LogicEditorResult failure(
LogicEditorError error, const std::string &message);
// 在一个条件单元应用节点或连线并计算下一光标
LogicEditResult applyCellAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig *config,
bool configured);
// 规整并检查一组控制逻辑,任一失败时返回首个错误
LogicSyntaxCheckResult checkSyntaxForLogics(
const std::vector<std::string> &logic_ids);

ProjectService &project_service_;
EditorHistory<HistoryState> history_;
ProjectService &project_service_; // 非拥有的工程服务依赖
EditorHistory<HistoryState> history_; // 当前逻辑编辑会话的撤销和重做历史
};

+ 13
- 0
app/src/services/offline_simulation_service.cpp 查看文件

@@ -2,6 +2,7 @@

#include <utility>

// 配置离线仓库和高精度定时器,并把超时信号连接到单轮扫描
OfflineSimulationService::OfflineSimulationService(
VirtualRegisterRepository &repository,
VirtualRegisterRepository *initial_repository,
@@ -12,10 +13,12 @@ OfflineSimulationService::OfflineSimulationService(
{
timer_.setInterval(kDefaultScanIntervalMs);
timer_.setTimerType(Qt::PreciseTimer);
// 每次扫描定时器到期都调用统一入口执行一轮离线梯形图
connect(&timer_, &QTimer::timeout,
this, &OfflineSimulationService::handleTimeout);
}

// 校验并保存逻辑快照后启动新的定时扫描会话
SimulationStartResult OfflineSimulationService::start(
const std::vector<ControlLogic> &logics)
{
@@ -51,6 +54,7 @@ SimulationStartResult OfflineSimulationService::start(
return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}};
}

// 清空运行值并重新装载编辑态确认的离线初始值
void OfflineSimulationService::restoreInitialValues()
{
repository_.clear();
@@ -60,6 +64,7 @@ void OfflineSimulationService::restoreInitialValues()
}
}

// 停止扫描、清理运行状态并恢复离线初始值
void OfflineSimulationService::stop()
{
timer_.stop();
@@ -76,6 +81,7 @@ void OfflineSimulationService::stop()
emit stateChanged();
}

// 立即执行一轮逻辑扫描并更新计数和运行轨迹
LogicScanResult OfflineSimulationService::executeOnce()
{
if (state_ != SimulationState::Running)
@@ -100,31 +106,38 @@ LogicScanResult OfflineSimulationService::executeOnce()
return result;
}

// 返回当前离线仿真生命周期状态
SimulationState OfflineSimulationService::state() const
{
return state_;
}

// 返回当前会话成功完成的扫描轮数
std::uint64_t OfflineSimulationService::successfulScanCount() const
{
return successful_scan_count_;
}

// 返回最近一次扫描错误
const LogicScanResult &OfflineSimulationService::lastError() const
{
return last_error_;
}

// 返回最近一轮扫描生成的逻辑运行轨迹
const LogicTraceSnapshot &OfflineSimulationService::traceSnapshot() const
{
return trace_snapshot_;
}

// 响应定时器超时并执行一轮扫描
void OfflineSimulationService::handleTimeout()
{
// 定时事件只负责触发,实际扫描和故障处理统一由 executeOnce 完成
executeOnce();
}

// 保存扫描错误、停止定时器并进入故障状态
void OfflineSimulationService::enterFault(const LogicScanResult &error)
{
// 故障后停止定时器和运行时状态,保留错误及最后轨迹供 UI 诊断


+ 16
- 0
app/src/services/online_logic_monitor_service.cpp 查看文件

@@ -5,11 +5,13 @@

namespace {

// 构造成功的本地轨迹扫描结果
LogicScanResult success()
{
return {true, LogicScanError::None, {}, {}, {}, {}};
}

// 构造带具体寄存器地址的 PLC 缓存读取失败结果
LogicScanResult snapshotFailure(const RegisterAddress &address)
{
return {
@@ -23,6 +25,7 @@ LogicScanResult snapshotFailure(const RegisterAddress &address)

} // namespace

// 绑定 PLC 读回缓存,所有本地输出会写入独立临时仓库
OnlineLogicMonitorService::OnlineLogicMonitorService(
RegisterRepository &plc_repository,
QObject *parent)
@@ -31,6 +34,7 @@ OnlineLogicMonitorService::OnlineLogicMonitorService(
{
}

// 保存当前逻辑快照和引用地址,并启动真机本地轨迹会话
OnlineLogicMonitorStartResult OnlineLogicMonitorService::start(
const std::vector<ControlLogic> &logics)
{
@@ -70,6 +74,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start(
}
}
}
// 固定按 M、D 区域和地址升序去重,保证每轮只复制实际引用值
std::sort(
addresses.begin(), addresses.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
@@ -90,6 +95,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start(
state_ = OnlineLogicMonitorState::Running;
emit stateChanged();

// 启动时立即复制一次 PLC 缓存并扫描,确保首份轨迹真实可用
const LogicScanResult first_scan = executeOnce();
if (!first_scan.succeeded)
{
@@ -101,6 +107,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start(
return {true, OnlineLogicMonitorStartError::None, success()};
}

// 停止本地轨迹推算并清除本次会话数据
void OnlineLogicMonitorService::stop()
{
executor_.resetRuntime();
@@ -118,6 +125,7 @@ void OnlineLogicMonitorService::stop()
emit stateChanged();
}

// 用最新 PLC 缓存副本执行一轮只影响临时仓库的本地轨迹推算
LogicScanResult OnlineLogicMonitorService::executeOnce()
{
if (state_ != OnlineLogicMonitorState::Running)
@@ -131,12 +139,14 @@ LogicScanResult OnlineLogicMonitorService::executeOnce()
{}};
}

// 扫描前只复制梯形图实际引用的 M/D,绝不把 PLC 仓库直接交给执行器
const LogicScanResult copied = copyPlcSnapshot();
if (!copied.succeeded)
{
enterFault(copied);
return copied;
}
// 线圈和数据指令只写临时仓库,扫描结果仅用于生成大屏亮线轨迹
const LogicScanResult result = executor_.executeScan(
logic_snapshot_, working_repository_, &trace_snapshot_);
if (!result.succeeded)
@@ -149,26 +159,31 @@ LogicScanResult OnlineLogicMonitorService::executeOnce()
return result;
}

// 返回当前真机本地轨迹生命周期状态
OnlineLogicMonitorState OnlineLogicMonitorService::state() const
{
return state_;
}

// 返回当前会话成功完成的本地推算轮数
std::uint64_t OnlineLogicMonitorService::successfulScanCount() const
{
return successful_scan_count_;
}

// 返回最近一次本地推算错误
const LogicScanResult &OnlineLogicMonitorService::lastError() const
{
return last_error_;
}

// 返回最近一轮按逻辑 ID 隔离的本地轨迹
const LogicTraceSnapshot &OnlineLogicMonitorService::traceSnapshot() const
{
return trace_snapshot_;
}

// 将真实 PLC 读回缓存复制到本轮独立的临时虚拟仓库
LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot()
{
// 每轮从真实读回缓存重新开始,本地输出不会跨轮污染 PLC 数据
@@ -197,6 +212,7 @@ LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot()
return success();
}

// 保存本地推算错误、清除边沿状态并进入故障态
void OnlineLogicMonitorService::enterFault(const LogicScanResult &error)
{
executor_.resetRuntime();


+ 33
- 19
app/src/services/online_logic_monitor_service.h 查看文件

@@ -8,26 +8,29 @@
#include <cstdint>
#include <vector>

// 真机本地轨迹推算的生命周期状态
enum class OnlineLogicMonitorState
{
Stopped,
Running,
Faulted
Stopped, // 未启动或已主动停止
Running, // 正在等待每轮 PLC 缓存更新后推算轨迹
Faulted // 最近一次缓存复制或本地扫描失败
};

// 启动真机本地轨迹推算失败的原因
enum class OnlineLogicMonitorStartError
{
None,
AlreadyRunning,
InvalidLogic,
PlcSnapshotUnavailable
None, // 启动成功
AlreadyRunning, // 当前已经处于运行态
InvalidLogic, // 启动前梯形图校验失败
PlcSnapshotUnavailable // 首次 PLC 缓存快照不可用
};

// 真机本地轨迹启动结果,detail 保存具体校验或扫描错误
struct OnlineLogicMonitorStartResult
{
bool succeeded = false;
OnlineLogicMonitorStartError error = OnlineLogicMonitorStartError::None;
LogicScanResult detail;
bool succeeded = false; // 是否成功启动并完成首次轨迹推算
OnlineLogicMonitorStartError error = OnlineLogicMonitorStartError::None; // 失败分类
LogicScanResult detail; // 执行器返回的详细错误和定位信息
};

// 使用 PLC 读回缓存推算本地梯形图轨迹,所有输出只写临时仓库
@@ -36,35 +39,46 @@ class OnlineLogicMonitorService final : public QObject
Q_OBJECT

public:
// 绑定只读 PLC 缓存仓库,不直接持有或控制通信连接
explicit OnlineLogicMonitorService(
RegisterRepository &plc_repository,
QObject *parent = nullptr);

// 校验并保存逻辑快照,随后立即使用 PLC 缓存推算首份轨迹
OnlineLogicMonitorStartResult start(const std::vector<ControlLogic> &logics);
// 停止推算并清除会话快照和轨迹
void stop();
// 复制最新 PLC 缓存并在临时仓库执行一轮本地扫描
LogicScanResult executeOnce();

// 返回当前真机本地轨迹生命周期状态
OnlineLogicMonitorState state() const;
// 返回当前会话成功完成的轨迹推算轮数
std::uint64_t successfulScanCount() const;
// 返回最近一次推算错误;主动停止不会清除故障详情,下一次 start 会重置
const LogicScanResult &lastError() const;
// 返回最近一轮按逻辑 ID 隔离的本地轨迹
const LogicTraceSnapshot &traceSnapshot() const;

signals:
// 生命周期状态发生变化时通知 UI
void stateChanged();
// 一轮本地轨迹推算成功后通知 UI
void scanCompleted();

private:
// 将梯形图实际引用的 PLC 缓存值复制到临时仓库
LogicScanResult copyPlcSnapshot();
// 保存错误、清理边沿状态并切换到故障态
void enterFault(const LogicScanResult &error);

RegisterRepository &plc_repository_;
VirtualRegisterRepository working_repository_;
SoftwareLogicExecutor executor_;
std::vector<ControlLogic> logic_snapshot_;
std::vector<RegisterAddress> referenced_addresses_;
OnlineLogicMonitorState state_ = OnlineLogicMonitorState::Stopped;
std::uint64_t successful_scan_count_ = 0;
LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}};
LogicTraceSnapshot trace_snapshot_;
RegisterRepository &plc_repository_; // 不拥有的 PLC 读回缓存仓库
VirtualRegisterRepository working_repository_; // 隔离本地输出的单轮临时仓库
SoftwareLogicExecutor executor_; // 校验逻辑并推算本地运行轨迹
std::vector<ControlLogic> logic_snapshot_; // 本次会话固定使用的逻辑快照
std::vector<RegisterAddress> referenced_addresses_; // 梯形图实际引用的去重地址
OnlineLogicMonitorState state_ = OnlineLogicMonitorState::Stopped; // 当前生命周期状态
std::uint64_t successful_scan_count_ = 0; // 当前会话成功推算次数
LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; // 最近错误
LogicTraceSnapshot trace_snapshot_; // 最近一轮本地推算轨迹
};

+ 7
- 0
app/src/services/plc_discovery_gateway.cpp 查看文件

@@ -14,6 +14,7 @@ struct SerialFrame
int stopBits = 1;
};

// 判断波特率是否属于项目允许的固定集合
bool isSupportedBaudRate(int baud_rate)
{
return baud_rate == 9600
@@ -23,6 +24,7 @@ bool isSupportedBaudRate(int baud_rate)
|| baud_rate == 115200;
}

// 判断数据位、校验位和停止位组合是否受支持
bool isSupportedFrame(const SerialFrame &frame)
{
return (frame.dataBits == 7 || frame.dataBits == 8)
@@ -30,12 +32,14 @@ bool isSupportedFrame(const SerialFrame &frame)
&& (frame.stopBits == 1 || frame.stopBits == 2);
}

// 判断两个串口帧配置是否完全相同
bool sameFrame(const SerialFrame &left, const SerialFrame &right)
{
return std::tie(left.dataBits, left.parity, left.stopBits)
== std::tie(right.dataBits, right.parity, right.stopBits);
}

// 按调用方提供的比较规则追加尚未出现的候选项
template<typename Value, typename Equals>
void appendUnique(std::vector<Value> *values, Value value, Equals equals)
{
@@ -49,6 +53,7 @@ void appendUnique(std::vector<Value> *values, Value value, Equals equals)

} // namespace

// 优先保留界面当前配置,再生成全部受支持的串口搜索组合
std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(
const PlcSerialConfiguration &preferred,
const std::vector<std::string> &available_ports)
@@ -70,6 +75,7 @@ std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(
ports.begin(), ports.end(), preferred.portName);
if (preferred_port != ports.end())
{
// rotate 只把首选端口移到开头,其余端口仍保持系统枚举顺序
std::rotate(ports.begin(), preferred_port, preferred_port + 1);
}

@@ -106,6 +112,7 @@ std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(

std::vector<PlcSerialConfiguration> candidates;
candidates.reserve(ports.size() * baud_rates.size() * frames.size());
// 端口放在最内层,让同一帧和波特率下优先快速遍历全部可用端口
for (const SerialFrame &frame : frames)
{
for (int baud_rate : baud_rates)


+ 7
- 7
app/src/services/plc_discovery_gateway.h 查看文件

@@ -10,18 +10,18 @@
// 自动搜索正在尝试的串口参数和总体进度
struct PlcDiscoveryProgress
{
PlcSerialConfiguration configuration;
std::size_t currentAttempt = 0;
std::size_t totalAttempts = 0;
PlcSerialConfiguration configuration; // 当前正在尝试的串口配置
std::size_t currentAttempt = 0; // 当前尝试序号,从 1 开始
std::size_t totalAttempts = 0; // 本次搜索需要尝试的组合总数
};

// 自动搜索结束结果;found 和 cancelled 不会同时为 true
struct PlcDiscoveryOutcome
{
bool found = false;
bool cancelled = false;
PlcSerialConfiguration configuration;
std::string message;
bool found = false; // 是否找到可响应的 PLC 配置
bool cancelled = false; // 是否由用户主动取消搜索
PlcSerialConfiguration configuration; // 搜索成功时匹配的串口配置
std::string message; // 搜索结果或失败原因
};

/**


+ 14
- 0
app/src/services/project_service.cpp 查看文件

@@ -24,6 +24,7 @@ std::string generateProjectId()

} // namespace

// 绑定工程存储和数量上限,并创建默认未命名工程
ProjectService::ProjectService(
ProjectStorage &storage,
const ProjectLimitSettings &project_limits)
@@ -33,17 +34,20 @@ ProjectService::ProjectService(
{
}

// 返回当前工程的只读视图
const Project &ProjectService::project() const
{
return project_;
}

// 标记工程已修改并返回可编辑引用
Project &ProjectService::editProject()
{
modified_ = true;
return project_;
}

// 返回当前工程是否已经关联文件路径
bool ProjectService::hasCurrentFile() const
{
return !current_file_path_.empty();
@@ -55,16 +59,19 @@ bool ProjectService::isModified() const
return modified_;
}

// 返回应用启动时确定的工程数量上限
const ProjectLimitSettings &ProjectService::projectLimits() const
{
return project_limits_;
}

// 恢复调用方进入编辑操作前保存的修改状态
void ProjectService::restoreModifiedState(bool modified)
{
modified_ = modified;
}

// 校验名称后创建未保存的新工程
ProjectOperationResult ProjectService::createNewProject(const std::string &name)
{
if (isBlank(name))
@@ -82,6 +89,7 @@ ProjectOperationResult ProjectService::createNewProject(const std::string &name)
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

// 将当前工程保存到已经关联的文件路径
ProjectOperationResult ProjectService::save()
{
if (!hasCurrentFile())
@@ -95,6 +103,7 @@ ProjectOperationResult ProjectService::save()
return saveAs(current_file_path_);
}

// 校验当前工程并保存到指定文件路径
ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
{
if (isBlank(file_path))
@@ -127,6 +136,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

// 导出工程副本且不改变当前文件关联和修改状态
ProjectOperationResult ProjectService::exportAs(const std::string &file_path) const
{
if (isBlank(file_path))
@@ -152,6 +162,7 @@ ProjectOperationResult ProjectService::exportAs(const std::string &file_path) co
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

// 复制当前工程并移除梯形图和地址备注,再保存为运行版临时工程
ProjectOperationResult ProjectService::exportHmiRuntimeAs(
const std::string &file_path) const
{
@@ -168,6 +179,7 @@ ProjectOperationResult ProjectService::exportHmiRuntimeAs(
runtime_project.controlLogics.clear();
runtime_project.registerComments.clear();
std::string validation_error;
// 保存前确认裁剪后的 HMI、报警和页面跳转可以独立运行
if (!runtime_project.validateForRunning(project_limits_, &validation_error))
{
return {false,
@@ -175,6 +187,7 @@ ProjectOperationResult ProjectService::exportHmiRuntimeAs(
ProjectStorageError::InvalidProject,
validation_error};
}
// 将裁剪结果写成临时 JSON,下一步会把它追加到运行版 EXE 尾部
const ProjectSaveResult result = storage_.save(runtime_project, file_path);
if (!result.succeeded)
{
@@ -183,6 +196,7 @@ ProjectOperationResult ProjectService::exportHmiRuntimeAs(
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

// 从 JSON 文件加载并校验工程,封装运行版启动时也复用这条入口
ProjectOperationResult ProjectService::load(const std::string &file_path)
{
if (isBlank(file_path))


+ 6
- 0
app/src/services/project_service.h 查看文件

@@ -36,8 +36,11 @@ public:
const Project &project() const;
// 获取服务层授权的可编辑引用,调用方必须随后完成校验
Project &editProject();
// 返回当前工程是否已经关联文件路径
bool hasCurrentFile() const;
// 返回当前工程是否有未保存修改
bool isModified() const;
// 返回应用启动时确定的工程数量上限
const ProjectLimitSettings &projectLimits() const;
// 失败编辑回滚时恢复进入编辑前的脏状态
void restoreModifiedState(bool modified);
@@ -74,8 +77,11 @@ public:
ProjectOperationResult load(const std::string &file_path);

private:
// 创建带唯一 ID 和当前格式版本的空白工程
static Project makeNewProject(const std::string &name);
// 判断字符串是否为空或只包含空白字符
static bool isBlank(const std::string &value);
// 将存储层错误统一转换为工程服务失败结果
static ProjectOperationResult storageFailure(
ProjectStorageError error, const std::string &message);



+ 7
- 0
app/src/services/register_comment_service.cpp 查看文件

@@ -8,6 +8,7 @@

namespace {

// 去除注释文本首尾空白
std::string trim(const std::string &value)
{
const auto first = std::find_if_not(
@@ -19,6 +20,7 @@ std::string trim(const std::string &value)
return first >= last ? std::string{} : std::string(first, last);
}

// 构造统一的注释操作失败结果
RegisterCommentResult failure(
RegisterCommentError error, const std::string &message)
{
@@ -27,22 +29,26 @@ RegisterCommentResult failure(

} // namespace

// 创建寄存器注释服务并绑定工程服务
RegisterCommentService::RegisterCommentService(ProjectService &project_service)
: project_service_(project_service)
{
}

// 返回当前工程中的全部寄存器注释
const std::vector<RegisterComment> &RegisterCommentService::comments() const
{
return project_service_.project().registerComments;
}

// 按地址查找寄存器注释
const RegisterComment *RegisterCommentService::findComment(
const RegisterAddress &address) const
{
return project_service_.project().findRegisterComment(address);
}

// 新建或更新指定地址的寄存器注释
RegisterCommentResult RegisterCommentService::setComment(
const RegisterAddress &address, const std::string &text)
{
@@ -82,6 +88,7 @@ RegisterCommentResult RegisterCommentService::setComment(
return {true, RegisterCommentError::None, {}};
}

// 删除指定地址的寄存器注释
RegisterCommentResult RegisterCommentService::removeComment(
const RegisterAddress &address)
{


+ 9
- 6
app/src/services/register_comment_service.h 查看文件

@@ -10,25 +10,27 @@ class ProjectService;
// 工程级寄存器注释操作的失败分类
enum class RegisterCommentError
{
None,
InvalidComment,
NotFound
None, // 操作成功
InvalidComment, // 地址或注释文本不符合领域规则
NotFound // 删除时没有找到指定地址的注释
};

// 注释编辑结果
struct RegisterCommentResult
{
bool succeeded = false;
RegisterCommentError error = RegisterCommentError::None;
std::string message;
bool succeeded = false; // 注释编辑是否成功
RegisterCommentError error = RegisterCommentError::None; // 失败分类
std::string message; // 面向用户的结果说明或失败原因
};

// 维护工程里的 M/D 注释,不读取也不修改寄存器实时值
class RegisterCommentService
{
public:
/** 创建寄存器注释服务并绑定工程服务 */
explicit RegisterCommentService(ProjectService &project_service);

/** 返回当前工程中的全部寄存器注释 */
const std::vector<RegisterComment> &comments() const;
// 按地址查找注释
const RegisterComment *findComment(const RegisterAddress &address) const;
@@ -39,5 +41,6 @@ public:
RegisterCommentResult removeComment(const RegisterAddress &address);

private:
/** 提供当前工程读写能力,不由本服务拥有 */
ProjectService &project_service_;
};

+ 34
- 0
app/src/services/register_monitor_service.cpp 查看文件

@@ -9,6 +9,7 @@

namespace {

// 将地址解析错误转换为用户可读提示
std::string parseErrorMessage(RegisterAddressParseError error)
{
switch (error)
@@ -28,33 +29,39 @@ std::string parseErrorMessage(RegisterAddressParseError error)

} // namespace

// 创建自由监控服务并绑定当前运行模式的寄存器仓库
RegisterMonitorService::RegisterMonitorService(RegisterRepository &repository)
: repository_(repository)
{
}

// 配置编辑态离线初始值仓库
void RegisterMonitorService::setOfflineInitialRepository(
VirtualRegisterRepository &repository)
{
offline_initial_repository_ = &repository;
}

// 设置编辑态写入是否同步捕获到离线初始值仓库
void RegisterMonitorService::setOfflineInitialCaptureEnabled(bool enabled)
{
capture_offline_initial_values_ = enabled;
}

// 配置编辑态可访问的 PLC 缓存仓库
void RegisterMonitorService::setEditingPlcRepository(
RegisterRepository &repository)
{
editing_plc_repository_ = &repository;
}

// 设置当前是否由编辑态数据监控访问仓库
void RegisterMonitorService::setEditingModeActive(bool active)
{
editing_mode_active_ = active;
}

// 更新编辑态 PLC 连接和首读状态
void RegisterMonitorService::setEditingPlcState(
bool connected, bool initial_read_completed)
{
@@ -62,6 +69,7 @@ void RegisterMonitorService::setEditingPlcState(
editing_plc_ready_ = connected && initial_read_completed;
}

// 切换编辑态数据监控使用的离线或 PLC 数据源
EditingMonitorSourceResult RegisterMonitorService::selectEditingSource(
EditingMonitorSource source)
{
@@ -90,31 +98,37 @@ EditingMonitorSourceResult RegisterMonitorService::selectEditingSource(
return {true, {}};
}

// 返回当前选择的编辑态数据源
EditingMonitorSource RegisterMonitorService::editingSource() const
{
return editing_source_;
}

// 返回编辑态 PLC 是否已连接
bool RegisterMonitorService::editingPlcConnected() const
{
return editing_plc_connected_ && editing_plc_repository_ != nullptr;
}

// 返回编辑态 PLC 是否已完成首读
bool RegisterMonitorService::editingPlcReady() const
{
return editingPlcConnected() && editing_plc_ready_;
}

// 判断当前数据源选择是否请求访问编辑态 PLC
bool RegisterMonitorService::editingPlcAccessRequested() const
{
return editing_mode_active_ && editing_source_ == EditingMonitorSource::Plc;
}

// 判断当前是否可以使用编辑态 PLC 仓库
bool RegisterMonitorService::usesEditingPlcRepository() const
{
return editingPlcAccessRequested() && editing_plc_repository_ != nullptr;
}

// 返回编辑态 PLC 不可用时的提示文字
std::string RegisterMonitorService::editingPlcUnavailableMessage() const
{
if (editing_plc_repository_ == nullptr)
@@ -128,22 +142,26 @@ std::string RegisterMonitorService::editingPlcUnavailableMessage() const
return "PLC 尚未完成首次读取";
}

// 返回当前数据源对应的可写寄存器仓库
RegisterRepository &RegisterMonitorService::accessRepository()
{
return usesEditingPlcRepository() ? *editing_plc_repository_ : repository_;
}

// 返回当前数据源对应的只读寄存器仓库
const RegisterRepository &RegisterMonitorService::accessRepository() const
{
return usesEditingPlcRepository() ? *editing_plc_repository_ : repository_;
}

// 添加使用默认数据类型的连续监控地址
RegisterMonitorResult RegisterMonitorService::addRange(
const std::string &start_address, int count)
{
return addRange(start_address, count, RegisterDataType::Int16);
}

// 添加指定数据类型的连续监控地址
RegisterMonitorResult RegisterMonitorService::addRange(
const std::string &start_address, int count, RegisterDataType data_type)
{
@@ -244,6 +262,7 @@ RegisterMonitorResult RegisterMonitorService::addRange(
static_cast<int>(pending.size())};
}

// 删除一批监控地址
RegisterMonitorResult RegisterMonitorService::remove(
const std::vector<RegisterAddress> &addresses)
{
@@ -266,6 +285,7 @@ RegisterMonitorResult RegisterMonitorService::remove(
return {true, RegisterMonitorError::None, {}, removed_count};
}

// 删除一批带数据类型的监控点
RegisterMonitorResult RegisterMonitorService::remove(
const std::vector<MonitorPoint> &points)
{
@@ -288,6 +308,7 @@ RegisterMonitorResult RegisterMonitorService::remove(
return {true, RegisterMonitorError::None, {}, removed_count};
}

// 清空当前会话的监控列表
RegisterMonitorResult RegisterMonitorService::clear()
{
if (model_.addresses().empty())
@@ -303,6 +324,7 @@ RegisterMonitorResult RegisterMonitorService::clear()
return {true, RegisterMonitorError::None, {}, removed_count};
}

// 向当前数据源提交 M 位写入
RegisterMonitorWriteResult RegisterMonitorService::writeBit(
const RegisterAddress &address, bool value)
{
@@ -324,6 +346,7 @@ RegisterMonitorWriteResult RegisterMonitorService::writeBit(
return captureInitialBit(address, value, active_result);
}

// 向当前数据源提交 D 数值写入
RegisterMonitorWriteResult RegisterMonitorService::writeNumeric(
const RegisterAddress &address, RegisterDataType data_type, double value)
{
@@ -365,6 +388,7 @@ RegisterMonitorWriteResult RegisterMonitorService::writeNumeric(
return captureInitialWords(address, *encoded, active_result);
}

// 将 M 位写入同步捕获到离线初始值仓库
RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit(
const RegisterAddress &address, bool value,
const RegisterMonitorWriteResult &active_result)
@@ -379,6 +403,7 @@ RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit(
return active_result;
}

// 将连续 D 字写入同步捕获到离线初始值仓库
RegisterMonitorWriteResult RegisterMonitorService::captureInitialWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values,
@@ -394,11 +419,13 @@ RegisterMonitorWriteResult RegisterMonitorService::captureInitialWords(
return active_result;
}

// 返回当前会话的监控地址
const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const
{
return model_.addresses();
}

// 读取监控点当前值并生成界面展示模型
std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const
{
std::vector<MonitorValue> result;
@@ -447,16 +474,19 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul
return result;
}

// 返回当前会话的监控点
const std::vector<MonitorPoint> &RegisterMonitorService::points() const
{
return model_.points();
}

// 返回当前监控点展开后的轮询地址
std::vector<RegisterAddress> RegisterMonitorService::pollAddresses() const
{
return pollAddressesForPoints(model_.points());
}

// 从监控点展开轮询地址并去重
std::vector<RegisterAddress> RegisterMonitorService::pollAddressesForPoints(
const std::vector<MonitorPoint> &points)
{
@@ -472,11 +502,13 @@ std::vector<RegisterAddress> RegisterMonitorService::pollAddressesForPoints(
return result;
}

// 返回当前监控点需要保持连续的多字范围
std::vector<RegisterWordRange> RegisterMonitorService::multiWordRanges() const
{
return multiWordRangesForPoints(model_.points());
}

// 从多字监控点提取连续范围并合并重叠区间
std::vector<RegisterWordRange> RegisterMonitorService::multiWordRangesForPoints(
const std::vector<MonitorPoint> &points)
{
@@ -492,11 +524,13 @@ std::vector<RegisterWordRange> RegisterMonitorService::multiWordRangesForPoints(
return result;
}

// 设置监控地址变化通知回调
void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback)
{
addresses_changed_callback_ = std::move(callback);
}

// 设置 PLC 轮询资源校验回调
void RegisterMonitorService::setPollConfigurationValidator(
std::function<std::string(
const std::vector<RegisterAddress> &,


+ 27
- 4
app/src/services/register_monitor_service.h 查看文件

@@ -41,15 +41,15 @@ struct RegisterMonitorWriteResult
// 编辑态数据监控由用户明确选择离线初始值或真实 PLC
enum class EditingMonitorSource
{
Offline,
Plc
Offline, // 当前进程内的离线初始值
Plc // 真实 PLC 的最新读回缓存
};

// 编辑态数据源切换结果;真机不满足连接和首读条件时拒绝切换
struct EditingMonitorSourceResult
{
bool succeeded = false;
std::string message;
bool succeeded = false; // 数据源是否切换成功
std::string message; // 切换失败时的用户提示
};

/**
@@ -92,6 +92,7 @@ public:
* @return 去重后实际新增数量;格式、范围、容量或无变化时返回失败
*/
RegisterMonitorResult addRange(const std::string &start_address, int count);
/** 使用指定数据类型连续添加同一区域的多个地址 */
RegisterMonitorResult addRange(
const std::string &start_address, int count, RegisterDataType data_type);
/**
@@ -100,6 +101,7 @@ public:
* @return 实际删除数量,并在发生变化时通知轮询集合更新
*/
RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses);
/** 删除一批带数据类型的监控点 */
RegisterMonitorResult remove(const std::vector<MonitorPoint> &points);
/**
* @brief 清空当前会话的监控地址列表
@@ -126,8 +128,11 @@ public:
* @return 按服务内部顺序保存的只读地址列表
*/
const std::vector<RegisterAddress> &addresses() const;
/** 返回当前会话的监控点及其数据类型 */
const std::vector<MonitorPoint> &points() const;
/** 返回供 PLC 轮询的地址集合 */
std::vector<RegisterAddress> pollAddresses() const;
/** 返回供 PLC 轮询使用的连续多字范围 */
std::vector<RegisterWordRange> multiWordRanges() const;
/**
* @brief 读取监控列表的当前值并生成 UI 展示模型
@@ -140,39 +145,57 @@ public:
* @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知
*/
void setAddressesChangedCallback(std::function<void()> callback);
/** 设置新增监控点前调用的 PLC 轮询资源校验器 */
void setPollConfigurationValidator(
std::function<std::string(
const std::vector<RegisterAddress> &,
const std::vector<RegisterWordRange> &)> validator);

private:
/** 返回当前编辑态数据源对应的实际仓库 */
RegisterRepository &accessRepository();
/** 返回当前编辑态数据源对应的只读仓库 */
const RegisterRepository &accessRepository() const;
/** 判断当前是否请求访问编辑态 PLC 数据 */
bool editingPlcAccessRequested() const;
/** 判断当前是否可以使用编辑态 PLC 仓库 */
bool usesEditingPlcRepository() const;
/** 返回编辑态 PLC 不可用时的提示文字 */
std::string editingPlcUnavailableMessage() const;
/** 将 M 位写入同步捕获到离线初始值仓库 */
RegisterMonitorWriteResult captureInitialBit(
const RegisterAddress &address, bool value,
const RegisterMonitorWriteResult &active_result);
/** 将连续 D 字写入同步捕获到离线初始值仓库 */
RegisterMonitorWriteResult captureInitialWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values,
const RegisterMonitorWriteResult &active_result);
/** 从监控点展开 PLC 轮询地址 */
static std::vector<RegisterAddress> pollAddressesForPoints(
const std::vector<MonitorPoint> &points);
/** 从多字监控点提取连续轮询范围 */
static std::vector<RegisterWordRange> multiWordRangesForPoints(
const std::vector<MonitorPoint> &points);

RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有
/** 编辑态直接读取的 PLC 缓存仓库,不由服务拥有 */
RegisterRepository *editing_plc_repository_ = nullptr;
/** 当前是否处于编辑态数据监控 */
bool editing_mode_active_ = false;
/** 用户当前选择的编辑态数据源 */
EditingMonitorSource editing_source_ = EditingMonitorSource::Offline;
/** 编辑态 PLC 当前是否连接 */
bool editing_plc_connected_ = false;
/** 编辑态 PLC 当前是否完成首读 */
bool editing_plc_ready_ = false;
/** 当前会话的离线初始值仓库,不由服务拥有 */
VirtualRegisterRepository *offline_initial_repository_ = nullptr;
/** 编辑态写入是否同步保存为离线初始值 */
bool capture_offline_initial_values_ = false;
RegisterMonitorModel model_; // 当前会话的去重监控地址列表
std::function<void()> addresses_changed_callback_; // 地址变化后的轮询刷新通知
/** 新监控点提交前使用的 PLC 轮询资源校验器 */
std::function<std::string(
const std::vector<RegisterAddress> &,
const std::vector<RegisterWordRange> &)> poll_configuration_validator_;


+ 51
- 0
app/src/services/runtime_mode_service.cpp 查看文件

@@ -15,6 +15,7 @@
#include <algorithm>
#include <utility>

// 创建运行模式服务并绑定工程、仿真和真机轨迹服务
RuntimeModeService::RuntimeModeService(
const ProjectService &project_service,
LogicEditorService &logic_editor_service,
@@ -27,6 +28,7 @@ RuntimeModeService::RuntimeModeService(
{
}

// 解除 PLC 状态回调绑定
RuntimeModeService::~RuntimeModeService()
{
if (plc_gateway_ != nullptr)
@@ -35,16 +37,19 @@ RuntimeModeService::~RuntimeModeService()
}
}

// 返回当前应用运行模式
ApplicationMode RuntimeModeService::mode() const
{
return state_.mode();
}

// 返回当前模式允许的编辑和数据访问策略
ModePolicy RuntimeModeService::policy() const
{
return state_.policy();
}

// 退出运行态并返回编辑态
ModeTransitionResult RuntimeModeService::enterEditing()
{
if (state_.mode() == ApplicationMode::OfflineRunning)
@@ -54,23 +59,29 @@ ModeTransitionResult RuntimeModeService::enterEditing()
}
else if (state_.mode() == ApplicationMode::OnlineRunning)
{
// 返回编辑态前停止使用 PLC 缓存副本推算本地轨迹
online_logic_monitor_service_.stop();
}
// 执行领域状态机切换,成功后才允许恢复编辑数据源
const ModeTransitionResult result = state_.enterEditing();
if (result.succeeded && active_repository_ != nullptr
&& virtual_repository_ != nullptr)
{
// 编辑态恢复虚拟 M/D,避免后续界面继续读写真机缓存
active_repository_->use(*virtual_repository_);
}
return result;
}

// 从编辑态进入离线运行态
ModeTransitionResult RuntimeModeService::enterOfflineRunning()
{
if (state_.mode() != ApplicationMode::Editing)
{
// 非编辑态的重复离线请求直接交给状态机判断是否合法
return state_.enterOfflineRunning();
}
// 启动离线扫描前先规整梯形图并检查整个工程能否运行
const ModeTransitionResult preparation = prepareProjectForRunning();
if (!preparation.succeeded)
{
@@ -79,6 +90,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
// 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存
if (active_repository_ != nullptr && virtual_repository_ != nullptr)
{
// HMI、报警和监控接下来统一读取离线虚拟 M/D
active_repository_->use(*virtual_repository_);
}
// start() 会复制逻辑快照并做运行前校验;失败时不进入运行态
@@ -88,6 +100,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
{
return {false, ModeTransitionError::SimulationStartFailed, {}};
}
// 离线扫描启动成功后再让状态机正式进入离线运行态
const ModeTransitionResult transition = state_.enterOfflineRunning();
if (!transition.succeeded)
{
@@ -96,16 +109,21 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
return transition;
}

// 首读和工程校验通过后进入真机运行,并将活动数据源切到 PLC 缓存
// 从编辑态进入真机运行态
ModeTransitionResult RuntimeModeService::enterOnlineRunning()
{
if (state_.mode() != ApplicationMode::Editing)
{
// 非编辑态的重复真机请求由状态机结合当前首读状态判断
return state_.enterOnlineRunning(initialPlcReadCompleted());
}
if (!initialPlcReadCompleted())
{
// PLC 尚未完整首读时让状态机返回明确的进入失败原因
return state_.enterOnlineRunning(false);
}
// 首读完成后仍要检查工程,避免用非法逻辑生成误导性的真机轨迹
const ModeTransitionResult preparation = prepareProjectForRunning();
if (!preparation.succeeded)
{
@@ -113,6 +131,7 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
}
if (!hmi_only_runtime_)
{
// 普通编程器保存逻辑快照并启动 PLC 缓存副本上的轨迹推算
const OnlineLogicMonitorStartResult start_result =
online_logic_monitor_service_.start(
project_service_.project().controlLogics);
@@ -121,9 +140,11 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
return {false, ModeTransitionError::SimulationStartFailed, {}};
}
}
// 轨迹服务就绪后再让状态机正式进入真机运行态
const ModeTransitionResult result = state_.enterOnlineRunning(true);
if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr)
{
// 真机 HMI 和自由监控统一改为读取 PLC 轮询缓存
active_repository_->use(*plc_repository_);
}
else if (!result.succeeded)
@@ -136,6 +157,8 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
return result;
}

// 标记 HMI 专用运行版,并从启动起固定使用 PLC 缓存数据源
// 设置用户运行版是否跳过本地梯形图执行器
void RuntimeModeService::setHmiOnlyRuntime(bool enabled)
{
hmi_only_runtime_ = enabled;
@@ -146,12 +169,16 @@ void RuntimeModeService::setHmiOnlyRuntime(bool enabled)
}
}

// 记录当前 PLC 连接是否已经完整读回全部运行地址
// 更新 PLC 首读完成状态并通知模式观察者
void RuntimeModeService::setInitialPlcReadCompleted(bool completed)
{
// 通信服务完成有效读回后才允许把此标志设为 true
initial_plc_read_completed_ = completed;
}

// 只有本地标记、网关状态和通信服务首读结果都有效时才确认首读完成
// 返回 PLC 是否已完成首读
bool RuntimeModeService::initialPlcReadCompleted() const
{
return initial_plc_read_completed_
@@ -160,36 +187,44 @@ bool RuntimeModeService::initialPlcReadCompleted() const
&& plc_gateway_->initialReadCompleted();
}

// 返回离线仿真当前状态
SimulationState RuntimeModeService::simulationState() const
{
return offline_simulation_service_.state();
}

// 返回离线仿真成功扫描轮数
std::uint64_t RuntimeModeService::successfulScanCount() const
{
return offline_simulation_service_.successfulScanCount();
}

// 返回离线仿真最近一次错误
const LogicScanResult &RuntimeModeService::simulationError() const
{
return offline_simulation_service_.lastError();
}

// 返回离线仿真服务引用
OfflineSimulationService &RuntimeModeService::offlineSimulationService()
{
return offline_simulation_service_;
}

// 返回真机只读轨迹服务引用
OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService()
{
return online_logic_monitor_service_;
}

// 返回最近一次运行前语法检查结果
const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const
{
return last_syntax_check_;
}

// 按普通编程器或 HMI 运行版规则执行进入运行态前的工程校验
// 执行运行前工程校验和梯形图规整
ModeTransitionResult RuntimeModeService::prepareProjectForRunning()
{
if (!hmi_only_runtime_)
@@ -224,6 +259,8 @@ ModeTransitionResult RuntimeModeService::prepareProjectForRunning()
return {true, ModeTransitionError::None, {}};
}

// 接入 PLC 网关和三套寄存器入口,并建立通信状态回调
// 注入 PLC 网关、活动仓库和两种数据源
void RuntimeModeService::configurePlc(
PlcCommunicationGateway &gateway,
ActiveRegisterRepository &active_repository,
@@ -273,6 +310,7 @@ void RuntimeModeService::configurePlc(
&& online_logic_monitor_service_.state()
== OnlineLogicMonitorState::Running)
{
// 每轮 PLC 地址全部读完后,用最新缓存副本重新推算一次本地轨迹
const LogicScanResult result =
online_logic_monitor_service_.executeOnce();
if (!result.succeeded
@@ -295,6 +333,8 @@ void RuntimeModeService::configurePlc(
});
}

// 汇总最新轮询地址、撤销旧首读资格并启动 PLC 异步连接
// 连接 PLC 并开始异步通信
PlcCommunicationResult RuntimeModeService::connectPlc(
const PlcSerialConfiguration &configuration)
{
@@ -306,21 +346,25 @@ PlcCommunicationResult RuntimeModeService::connectPlc(
{
plc_gateway_->disconnectDevice();
}
// 连接前按当前工程重新收集 HMI、报警、监控和逻辑引用地址
const PlcCommunicationResult poll_result = refreshPlcPollAddresses();
if (!poll_result.succeeded)
{
return poll_result;
}
setInitialPlcReadCompleted(false);
// 将串口配置交给通信服务,开始异步打开串口并连接 PLC
return plc_gateway_->connectDevice(configuration);
}

// 设置自由监控地址并刷新 PLC 轮询集合
PlcCommunicationResult RuntimeModeService::setMonitorAddresses(
const std::vector<RegisterAddress> &addresses)
{
return setMonitorAddresses(addresses, {});
}

// 设置自由监控地址和多字范围并刷新 PLC 轮询集合
PlcCommunicationResult RuntimeModeService::setMonitorAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterWordRange> &multi_word_ranges)
@@ -338,6 +382,8 @@ PlcCommunicationResult RuntimeModeService::setMonitorAddresses(
return result;
}

// 汇总监控、HMI、报警和梯形图引用,生成 PLC 实际轮询地址集合
// 汇总工程和自由监控地址后更新 PLC 轮询配置
PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
{
if (plc_gateway_ == nullptr)
@@ -442,9 +488,11 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
{
return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"};
}
// 只把工程实际引用且去重后的 M/D 交给通信层分块轮询
return plc_gateway_->setPollAddresses(addresses, multi_word_ranges);
}

// 断开 PLC 并清除真机首读资格
void RuntimeModeService::disconnectPlc()
{
if (state_.mode() == ApplicationMode::OnlineRunning)
@@ -458,18 +506,21 @@ void RuntimeModeService::disconnectPlc()
setInitialPlcReadCompleted(false);
}

// 返回当前 PLC 连接状态
PlcConnectionState RuntimeModeService::plcConnectionState() const
{
return plc_gateway_ == nullptr
? PlcConnectionState::Disconnected : plc_gateway_->state();
}

// 返回最近一次 PLC 通信错误
const std::string &RuntimeModeService::plcError() const
{
static const std::string empty;
return plc_gateway_ == nullptr ? empty : plc_gateway_->lastError();
}

// 设置 PLC 状态变化通知回调
void RuntimeModeService::setPlcStatusChangedCallback(std::function<void()> callback)
{
plc_status_changed_callback_ = std::move(callback);


+ 3
- 0
app/src/services/runtime_mode_service.h 查看文件

@@ -87,6 +87,7 @@ public:
const LogicScanResult &simulationError() const;
/** @brief 返回服务持有的离线仿真服务引用 */
OfflineSimulationService &offlineSimulationService();
/** 返回服务持有的真机只读轨迹服务引用 */
OnlineLogicMonitorService &onlineLogicMonitorService();
/** 返回最近一次进入运行模式前执行的梯形图语法检查结果 */
const LogicSyntaxCheckResult &lastSyntaxCheck() const;
@@ -116,6 +117,7 @@ public:
*/
PlcCommunicationResult setMonitorAddresses(
const std::vector<RegisterAddress> &addresses);
/** 设置自由监控地址和多字范围并刷新 PLC 轮询集合 */
PlcCommunicationResult setMonitorAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterWordRange> &multi_word_ranges);
@@ -144,6 +146,7 @@ public:
void setPlcStatusChangedCallback(std::function<void()> callback);

private:
/** 执行运行前工程检查并规整梯形图 */
ModeTransitionResult prepareProjectForRunning();

const ProjectService &project_service_; // 不拥有的只读工程服务


+ 21
- 0
app/src/services/software_logic_executor.cpp 查看文件

@@ -7,11 +7,13 @@

namespace {

// 构造成功的软件扫描结果
LogicScanResult success()
{
return {true, LogicScanError::None, {}, {}, {}, {}};
}

// 构造带定位信息的软件扫描失败结果
LogicScanResult failure(
LogicScanError error,
const std::string &message,
@@ -22,6 +24,7 @@ LogicScanResult failure(
return {false, error, message, logic_id, rung_id, node_id};
}

// 按比较运算符计算两个 16 位字值的关系
bool compareWord(
std::int16_t actual,
ComparisonOperator comparison,
@@ -60,6 +63,7 @@ bool compareWord(
}
}

// 判断同一 M 地址上的两种线圈模式是否允许共存
bool coilModesAreCompatible(CoilMode existing, CoilMode current)
{
if (existing == current)
@@ -71,6 +75,7 @@ bool coilModesAreCompatible(CoilMode existing, CoilMode current)

} // namespace

// 清除当前逻辑或网络的全部运行轨迹
void LogicTraceValues::clear()
{
nodeValues.clear();
@@ -83,12 +88,14 @@ void LogicTraceValues::clear()
wordValues.clear();
}

// 清除全工程及按逻辑分组的运行轨迹
void LogicTraceSnapshot::clear()
{
LogicTraceValues::clear();
logicValues.clear();
}

// 返回指定逻辑的独立轨迹投影
LogicTraceSnapshot LogicTraceSnapshot::forLogic(
const std::string &logic_id) const
{
@@ -109,6 +116,7 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic(
return projection;
}

// 检查控制逻辑是否满足软件扫描要求
LogicScanResult SoftwareLogicExecutor::validate(
const std::vector<ControlLogic> &logics) const
{
@@ -174,11 +182,13 @@ LogicScanResult SoftwareLogicExecutor::validate(
return success();
}

// 清除边沿触点跨扫描保存的上一轮输入
void SoftwareLogicExecutor::resetRuntime()
{
previous_edge_inputs_.clear();
}

// 按工程顺序执行一轮完整的软件逻辑扫描
LogicScanResult SoftwareLogicExecutor::executeScan(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
@@ -227,12 +237,14 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
++group_end;
}

// 每个竖线连通组按列传播电源,组内行共享对应列边界的导通状态
const std::size_t group_size = group_end - group_start + 1U;
std::vector<bool> power(group_size, true);
for (int boundary = 0;
boundary <= ProjectLimits::kMaximumConditionColumns;
++boundary)
{
// 用并查集合并当前列边界上的竖线,避免反复沿上下支路递归查找
std::vector<std::size_t> parent(group_size);
for (std::size_t index = 0U; index < group_size; ++index)
{
@@ -278,6 +290,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
}
}

// 同一连通分量任意一行带电,当前边界上的全部成员都视为带电
std::vector<bool> component_power(group_size, false);
for (std::size_t index = 0U; index < group_size; ++index)
{
@@ -330,6 +343,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
LogicScanResult result = success();
if (cell.kind == LadderCellKind::Node)
{
// 条件格是 Node 时读取 M/D,并计算触点或比较条件结果
result = evaluateCondition(
logic.id,
*cell.node,
@@ -374,6 +388,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
continue;
}
bool output_value = false;
// 使用这一行最终导通状态执行线圈或数据输出指令
LogicScanResult result = executeOutput(
*rung.output,
rung_value,
@@ -397,6 +412,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
}
if (trace != nullptr)
{
// 顶层兼容视图只投影第一条启用逻辑,完整轨迹仍保存在 logicValues 中
const auto first_enabled = std::find_if(
logics.cbegin(), logics.cend(),
[](const ControlLogic &logic) { return logic.enabled; });
@@ -420,6 +436,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
return success();
}

// 读取寄存器并计算单个条件节点的导通结果
LogicScanResult SoftwareLogicExecutor::evaluateCondition(
const std::string &logic_id,
const LogicNode &node,
@@ -438,6 +455,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
node.id);
}

// std::visit 根据 variant 的实际节点类型在编译期选择对应读取分支
return std::visit(
[this, &logic_id, &repository, value, &node](
const auto &config) -> LogicScanResult
@@ -510,6 +528,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
node.config);
}

// 解析常量或 D 寄存器字操作数
LogicScanResult SoftwareLogicExecutor::readWordOperand(
const WordOperand &operand,
RegisterRepository &repository,
@@ -553,6 +572,7 @@ LogicScanResult SoftwareLogicExecutor::readWordOperand(
return success();
}

// 根据网络结果执行线圈或字运算输出
LogicScanResult SoftwareLogicExecutor::executeOutput(
const LogicNode &node,
bool rung_value,
@@ -622,6 +642,7 @@ LogicScanResult SoftwareLogicExecutor::executeOutput(
{
return result;
}
// 先用 32 位计算检测溢出,再饱和收敛到 D 字允许的 16 位范围
const std::int32_t raw = arithmetic->operation == ArithmeticOperation::Add
? static_cast<std::int32_t>(left) + right
: static_cast<std::int32_t>(left) - right;


+ 36
- 0
app/src/services/user_runtime_plc_recovery_policy.cpp 查看文件

@@ -0,0 +1,36 @@
#include "user_runtime_plc_recovery_policy.h"

// 判断当前状态是否需要完整重新打开串口
bool UserRuntimePlcRecoveryPolicy::shouldReconnectSerial(
PlcConnectionState state) const
{
return state == PlcConnectionState::Disconnected;
}

// 开始一次故障过程,并保证同一次故障只允许提示一次
bool UserRuntimePlcRecoveryPolicy::beginFailureEpisode()
{
if (failure_episode_active_)
{
return false;
}
failure_episode_active_ = true;
return true;
}

// 连接并重新完成首读后结束本次故障过程
void UserRuntimePlcRecoveryPolicy::observeStatus(
PlcConnectionState state,
bool initial_read_completed)
{
if (state == PlcConnectionState::Connected && initial_read_completed)
{
failure_episode_active_ = false;
}
}

// 返回当前是否仍处于尚未恢复的通信故障过程
bool UserRuntimePlcRecoveryPolicy::failureEpisodeActive() const
{
return failure_episode_active_;
}

+ 23
- 0
app/src/services/user_runtime_plc_recovery_policy.h 查看文件

@@ -0,0 +1,23 @@
#pragma once

#include "services/plc_communication_gateway.h"

// 管理用户运行版的串口重连条件和一次通信故障的提示周期
class UserRuntimePlcRecoveryPolicy final
{
public:
/** 判断当前状态是否需要完整重新打开串口 */
bool shouldReconnectSerial(PlcConnectionState state) const;
/** 开始一次故障过程,首次调用返回 true,后续调用不重复提示 */
bool beginFailureEpisode();
/** 观察通信状态,连接且完成首读后结束本轮故障 */
void observeStatus(
PlcConnectionState state,
bool initial_read_completed);
/** 返回当前是否仍处于未恢复的故障过程 */
bool failureEpisodeActive() const;

private:
/** 当前是否已经进入一次尚未恢复的故障过程 */
bool failure_episode_active_ = false;
};

+ 14
- 0
app/src/ui/alarm_configuration_dialog.cpp 查看文件

@@ -10,17 +10,20 @@

namespace {

// 将 UTF-8 标准字符串转换为 QString
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 将 QString 按 UTF-8 转换为标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

// 返回报警触发条件对应的界面文字
QString conditionText(AlarmCondition condition)
{
switch (condition)
@@ -50,6 +53,7 @@ QString conditionText(AlarmCondition condition)

} // namespace

// 创建报警配置对话框并连接表格和编辑动作
AlarmConfigurationDialog::AlarmConfigurationDialog(
AlarmEditorService &service,
QWidget *parent)
@@ -90,8 +94,10 @@ AlarmConfigurationDialog::AlarmConfigurationDialog(
reloadDefinitions();
}

// 释放报警配置对话框
AlarmConfigurationDialog::~AlarmConfigurationDialog() = default;

// 重新加载报警列表并尽量恢复指定报警的选中状态
void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id)
{
const auto &definitions = service_.definitions();
@@ -102,6 +108,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id)
const AlarmDefinition &definition = definitions[index];
const int row = static_cast<int>(index);
auto *id_item = new QTableWidgetItem(fromUtf8(definition.id));
// UserRole 保存稳定标识,表格排序或显示文字变化后仍能找到原定义
id_item->setData(Qt::UserRole, fromUtf8(definition.id));
ui_->alarmTable->setItem(row, 0, id_item);
ui_->alarmTable->setItem(
@@ -135,6 +142,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id)
}
}

// 将当前选中的报警定义加载到编辑控件
void AlarmConfigurationDialog::loadSelectedDefinition()
{
const AlarmDefinition *definition = service_.findDefinition(selectedId());
@@ -156,6 +164,7 @@ void AlarmConfigurationDialog::loadSelectedDefinition()
ui_->messageEdit->setText(fromUtf8(definition->message));
}

// 根据 M 位或 D 字区域刷新可选触发条件和阈值输入状态
void AlarmConfigurationDialog::updateConditionOptions()
{
const RegisterArea area = static_cast<RegisterArea>(
@@ -183,6 +192,7 @@ void AlarmConfigurationDialog::updateConditionOptions()
ui_->thresholdSpinBox->setEnabled(area == RegisterArea::D);
}

// 从界面读取配置并新增报警定义
void AlarmConfigurationDialog::addDefinition()
{
const AlarmEditorResult result = service_.addDefinition(definitionFromInputs());
@@ -194,6 +204,7 @@ void AlarmConfigurationDialog::addDefinition()
reloadDefinitions(result.id);
}

// 使用界面中的配置更新当前报警定义
void AlarmConfigurationDialog::updateDefinition()
{
const std::string id = selectedId();
@@ -211,6 +222,7 @@ void AlarmConfigurationDialog::updateDefinition()
reloadDefinitions(result.id);
}

// 确认后删除当前选中的报警定义
void AlarmConfigurationDialog::removeDefinition()
{
const std::string id = selectedId();
@@ -233,6 +245,7 @@ void AlarmConfigurationDialog::removeDefinition()
reloadDefinitions();
}

// 将编辑控件中的值组装成报警定义
AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const
{
AlarmDefinition definition;
@@ -247,6 +260,7 @@ AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const
return definition;
}

// 从表格自定义数据中读取当前报警标识
std::string AlarmConfigurationDialog::selectedId() const
{
const int row = ui_->alarmTable->currentRow();


+ 8
- 0
app/src/ui/button_extension_dialog.cpp 查看文件

@@ -10,6 +10,7 @@

namespace {

// 将 UTF-8 标准字符串转换为 QString
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
@@ -17,6 +18,7 @@ QString fromUtf8(const std::string &value)

} // namespace

// 创建对话框并将按钮现有启用条件加载到对应配置页
ButtonExtensionDialog::ButtonExtensionDialog(
const HmiControl &control, QWidget *parent)
: QDialog(parent),
@@ -42,6 +44,7 @@ ButtonExtensionDialog::ButtonExtensionDialog(

if (control.buttonEnableCondition.has_value())
{
// variant 保存 M 位或 D 数值两种条件,按实际类型恢复界面
if (const auto *bit = std::get_if<HmiButtonBitEnableCondition>(
&*control.buttonEnableCondition))
{
@@ -67,13 +70,16 @@ ButtonExtensionDialog::ButtonExtensionDialog(
updateConditionPage();
}

// 释放按钮扩展配置对话框
ButtonExtensionDialog::~ButtonExtensionDialog() = default;

// 返回用户确认后更新的按钮控件
const HmiControl &ButtonExtensionDialog::control() const
{
return control_;
}

// 根据条件类型切换 M 位或 D 数值配置页面
void ButtonExtensionDialog::updateConditionPage()
{
const int type = ui_->conditionTypeComboBox->currentData().toInt();
@@ -82,6 +88,7 @@ void ButtonExtensionDialog::updateConditionPage()
ui_->wordPage->setEnabled(type == 2);
}

// 读取、校验并保存按钮启用条件
void ButtonExtensionDialog::accept()
{
const int type = ui_->conditionTypeComboBox->currentData().toInt();
@@ -98,6 +105,7 @@ void ButtonExtensionDialog::accept()
else
{
bool converted = false;
// 使用固定 C locale,确保工程数值不受系统小数点格式影响
const double value = QLocale::c().toDouble(
ui_->valueEdit->text().trimmed(), &converted);
if (!converted)


+ 15
- 0
app/src/ui/button_extension_dialog.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file button_extension_dialog.h
* @brief 定义 HMI 按钮启用条件配置对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-30
*/

#pragma once

#include "domain/hmi_model.h"
@@ -15,16 +23,23 @@ class ButtonExtensionDialog final : public QDialog
Q_OBJECT

public:
/** 创建对话框并加载按钮现有的启用条件 */
explicit ButtonExtensionDialog(const HmiControl &control, QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~ButtonExtensionDialog() override;

/** 返回用户确认后更新的按钮控件 */
const HmiControl &control() const;

private slots:
/** 根据条件类型切换 M 位或 D 数值配置页面 */
void updateConditionPage();
/** 校验并保存按钮启用条件 */
void accept() override;

private:
/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::ButtonExtensionDialog> ui_;
/** 正在编辑的按钮控件副本 */
HmiControl control_;
};

+ 36
- 0
app/src/ui/free_monitor_widget.cpp 查看文件

@@ -23,18 +23,22 @@

namespace {

// 将 UTF-8 标准字符串转换为 QString
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 返回寄存器地址对应的界面文字
QString addressText(const RegisterAddress &address)
{
return QString::fromStdString(address.toString());
}

// 将不同数值类型的寄存器值格式化为无精度损失的文字
QString numericValueText(const RegisterNumericValue &value)
{
// visit 在编译期按 variant 中的实际数值类型选择格式
return std::visit(
[](const auto &typed_value)
{
@@ -60,6 +64,7 @@ QString numericValueText(const RegisterNumericValue &value)

} // namespace

// 创建自由监控控件并连接地址、数据源和写入操作
FreeMonitorWidget::FreeMonitorWidget(
RegisterMonitorService &service,
QWidget *parent)
@@ -88,6 +93,8 @@ FreeMonitorWidget::FreeMonitorWidget(
connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses);
connect(ui_->addressEdit, &QLineEdit::returnPressed,
this, &FreeMonitorWidget::addAddresses);
connect(ui_->addressEdit, &QLineEdit::textChanged,
this, &FreeMonitorWidget::updateDataTypeSelector);
connect(ui_->removeButton, &QToolButton::clicked,
this, &FreeMonitorWidget::removeSelectedAddresses);
connect(ui_->clearButton, &QToolButton::clicked,
@@ -98,20 +105,24 @@ FreeMonitorWidget::FreeMonitorWidget(
reloadAddresses();
}

// 释放自由监控控件
FreeMonitorWidget::~FreeMonitorWidget() = default;

// 设置当前是否允许写入寄存器
void FreeMonitorWidget::setWriteEnabled(bool enabled)
{
write_enabled_ = enabled;
updateWriteControls();
}

// 设置编辑态数据源选择控件是否可见
void FreeMonitorWidget::setEditingSourceSelectorVisible(bool visible)
{
ui_->editingSourceLabel->setVisible(visible);
ui_->editingSourceComboBox->setVisible(visible);
}

// 根据运行模式和 PLC 状态刷新数据源提示和监控值
void FreeMonitorWidget::refreshValues(
ApplicationMode mode, PlcConnectionState plc_state)
{
@@ -156,6 +167,7 @@ void FreeMonitorWidget::refreshValues(
values_available = true;
communication_fault = plc_selected
&& plc_state == PlcConnectionState::Faulted;
// 程序同步选项时阻断信号,避免被当成用户主动切换数据源
const QSignalBlocker blocker(ui_->editingSourceComboBox);
ui_->editingSourceComboBox->setCurrentIndex(plc_selected ? 1 : 0);
}
@@ -224,6 +236,7 @@ void FreeMonitorWidget::refreshValues(
updateWriteControls();
}

// 按服务中的监控点重建表格行和写入控件
void FreeMonitorWidget::reloadAddresses()
{
const std::vector<RegisterAddress> &addresses = service_.addresses();
@@ -290,6 +303,7 @@ void FreeMonitorWidget::reloadAddresses()
ui_->monitorTable->setCellWidget(row, 3, target);
}
auto *write_button = new QPushButton(tr("写入"), ui_->monitorTable);
// 行号保存在按钮动态属性中,点击时可以找到对应监控点
write_button->setProperty("monitorRow", row);
connect(write_button, &QPushButton::clicked, this,
[this, write_button]
@@ -302,6 +316,7 @@ void FreeMonitorWidget::reloadAddresses()
updateWriteControls();
}

// 读取指定表格行的目标值并请求服务写入寄存器
void FreeMonitorWidget::writeRow(int row)
{
if (!write_enabled_ || row < 0
@@ -330,6 +345,7 @@ void FreeMonitorWidget::writeRow(int row)
return;
}
bool converted = false;
// 使用固定 C locale 解析数值,避免系统区域格式改变输入含义
const double value = QLocale::c().toDouble(target->text(), &converted);
if (!converted
|| !encodeRegisterNumericValue(point.dataType, value).has_value())
@@ -356,6 +372,7 @@ void FreeMonitorWidget::writeRow(int row)
handleResult(tr("写入 %1").arg(addressText(address)), false, result.message);
}

// 将当前写入权限同步到每一行的输入控件和按钮
void FreeMonitorWidget::updateWriteControls()
{
for (int row = 0; row < ui_->monitorTable->rowCount(); ++row)
@@ -373,6 +390,20 @@ void FreeMonitorWidget::updateWriteControls()
}
}

// M 地址固定为位类型,D 地址允许选择数值类型
void FreeMonitorWidget::updateDataTypeSelector(const QString &address_text)
{
const bool bit_address = address_text.trimmed().startsWith(
QLatin1Char('M'), Qt::CaseInsensitive);
ui_->dataTypeComboBox->setCurrentIndex(0);
ui_->dataTypeComboBox->setItemText(
0, bit_address ? tr("Bit(位)") : QStringLiteral("Int16"));
ui_->dataTypeComboBox->setEnabled(!bit_address);
ui_->dataTypeComboBox->setToolTip(
bit_address ? tr("M 地址固定使用位类型") : QString{});
}

// 将输入的起始地址和数量添加到自由监控列表
void FreeMonitorWidget::addAddresses()
{
const QByteArray address = ui_->addressEdit->text().toUtf8();
@@ -397,6 +428,7 @@ void FreeMonitorWidget::addAddresses()
handleResult(tr("添加监控地址"), false, result.message);
}

// 删除表格中选中的所有监控点
void FreeMonitorWidget::removeSelectedAddresses()
{
std::set<int> rows;
@@ -424,6 +456,7 @@ void FreeMonitorWidget::removeSelectedAddresses()
handleResult(tr("删除监控地址"), false, result.message);
}

// 确认后清空当前会话的自由监控列表
void FreeMonitorWidget::clearAddresses()
{
if (service_.addresses().empty())
@@ -447,6 +480,7 @@ void FreeMonitorWidget::clearAddresses()
handleResult(tr("清空自由监控"), false, result.message);
}

// 请求切换编辑态监控数据源,失败时恢复服务中的实际选项
void FreeMonitorWidget::selectEditingSource(int index)
{
if (index < 0)
@@ -459,6 +493,7 @@ void FreeMonitorWidget::selectEditingSource(int index)
service_.selectEditingSource(source);
if (!result.succeeded)
{
// 恢复下拉框时阻断信号,避免失败操作反复触发
const QSignalBlocker blocker(ui_->editingSourceComboBox);
ui_->editingSourceComboBox->setCurrentIndex(
service_.editingSource() == EditingMonitorSource::Plc ? 1 : 0);
@@ -472,6 +507,7 @@ void FreeMonitorWidget::selectEditingSource(int index)
emit editingSourceChanged();
}

// 将监控服务的失败结果转换为界面消息
void FreeMonitorWidget::handleResult(
const QString &action, bool succeeded, const std::string &message)
{


+ 2
- 0
app/src/ui/free_monitor_widget.h 查看文件

@@ -64,6 +64,8 @@ private:
void writeRow(int row);
/** 根据当前权限刷新写入控件状态 */
void updateWriteControls();
/** 根据地址区域切换可选的数据类型 */
void updateDataTypeSelector(const QString &address_text);
/** 统一处理监控服务返回结果 */
void handleResult(const QString &action, bool succeeded, const std::string &message);



+ 58
- 1
app/src/ui/hmi_editor_widget.cpp 查看文件

@@ -33,6 +33,7 @@

namespace {

// 报警列表使用固定行高和列宽计算绘制及点击区域
constexpr qreal kAlarmHeaderHeight = 24.0;
constexpr qreal kAlarmRowHeight = 22.0;
constexpr qreal kAlarmCellLeftPadding = 5.0;
@@ -43,6 +44,7 @@ constexpr qreal kAlarmStateColumnWidth = 52.0;
constexpr qreal kAlarmPageButtonSize = 16.0;
constexpr qreal kAlarmPageIndicatorWidth = 34.0;

// 将报警时间转换为时分秒显示文字
QString alarmTimeText(const std::chrono::system_clock::time_point &time)
{
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(
@@ -50,8 +52,10 @@ QString alarmTimeText(const std::chrono::system_clock::time_point &time)
return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss"));
}

// 将不同数值类型的寄存器值格式化为无精度损失的文字
QString numericValueText(const RegisterNumericValue &value)
{
// visit 在编译期按 variant 中的实际数值类型选择格式
return std::visit(
[](const auto &typed_value)
{
@@ -75,8 +79,10 @@ QString numericValueText(const RegisterNumericValue &value)
value);
}

// 将类型化寄存器数值统一转换为输入对话框使用的 double
double numericValueAsDouble(const RegisterNumericValue &value)
{
// std::visit 统一处理整数、浮点数等不同寄存器数值类型
return std::visit(
[](const auto &typed_value)
{
@@ -85,6 +91,7 @@ double numericValueAsDouble(const RegisterNumericValue &value)
value);
}

// 弹出支持小数和科学计数法的浮点数输入对话框
std::optional<double> requestFloatingPointInput(
QWidget *parent,
const HmiControl &control,
@@ -108,6 +115,7 @@ std::optional<double> requestFloatingPointInput(
dialog.setTextValue(QString::number(
has_current_value ? numericValueAsDouble(current_value) : 0.0,
'g', digits));
// QInputDialog 没有直接暴露验证器,通过内部输入框安装范围验证
QLineEdit *editor = dialog.findChild<QLineEdit *>();
if (editor != nullptr)
{
@@ -122,6 +130,7 @@ std::optional<double> requestFloatingPointInput(
return std::nullopt;
}
bool converted = false;
// 使用固定 C locale,确保输入格式不受系统区域设置影响
const double value = QLocale::c().toDouble(dialog.textValue(), &converted);
return converted && encodeRegisterNumericValue(control.dataType, value).has_value()
? std::optional<double>{value} : std::nullopt;
@@ -154,7 +163,7 @@ public:
setPos(control_.bounds.x, control_.bounds.y);
// 所有控件均可选中,便于主窗口显示对应属性
setFlag(ItemIsSelectable, true);
// 开启可选中
// 开启位置变化通知,让 itemChange 可以限制拖动坐标
setFlag(ItemSendsGeometryChanges, true);
// 画布只处理左键交互,保留其他按键给视图默认行为
setAcceptedMouseButtons(Qt::LeftButton);
@@ -170,6 +179,7 @@ public:
return controlRect().united(addressRect());
}

// 根据控件类型、运行值和交互状态绘制完整 HMI 控件
void paint(
QPainter *painter,
const QStyleOptionGraphicsItem *option,
@@ -529,6 +539,7 @@ public:
update();
}

// 更新按钮启用条件结果并重新计算可交互状态
void setButtonConditionEnabled(bool enabled)
{
button_condition_enabled_ = enabled;
@@ -536,6 +547,7 @@ public:
editing_enabled_, runtime_active_, runtime_write_enabled_);
}

// 缓存状态文本读取结果并触发重绘
void setStatusText(const std::string &text, bool available)
{
status_text_ = QString::fromUtf8(
@@ -544,6 +556,7 @@ public:
update();
}

// 更新报警记录并将当前页限制在有效分页范围内
void setAlarmRecords(const std::vector<AlarmRecord> &records)
{
alarm_records_ = records;
@@ -690,6 +703,7 @@ protected:
}
}

// 鼠标进入时更新按钮或页面跳转控件的悬停效果
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override
{
if (runtime_active_ && runtime_write_enabled_
@@ -706,6 +720,7 @@ protected:
QGraphicsItem::hoverEnterEvent(event);
}

// 鼠标离开时清除运行态悬停效果
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override
{
if (button_hovered_)
@@ -722,6 +737,7 @@ protected:
}

private:
// 根据控件高度和项目上限计算报警列表每页可见行数
std::size_t alarmPageSize() const
{
const QRectF rect = controlRect().adjusted(1, 1, -1, -1);
@@ -733,6 +749,7 @@ private:
rows_by_height, ProjectLimits::kMaximumVisibleAlarmRows));
}

// 根据记录数量和每页行数计算报警总页数
std::size_t alarmPageCount() const
{
const std::size_t page_size = alarmPageSize();
@@ -741,11 +758,13 @@ private:
: (alarm_records_.size() + page_size - 1U) / page_size;
}

// 返回当前报警页第一条记录的下标
std::size_t alarmFirstRecordIndex() const
{
return alarm_page_ * alarmPageSize();
}

// 返回当前报警页实际可见的记录数量
std::size_t visibleAlarmRecordCount() const
{
const std::size_t first_record = alarmFirstRecordIndex();
@@ -757,6 +776,7 @@ private:
alarmPageSize(), alarm_records_.size() - first_record);
}

// 计算报警标题栏下一页按钮的点击区域
QRectF alarmNextPageRect(const QRectF &header) const
{
return {
@@ -766,6 +786,7 @@ private:
kAlarmPageButtonSize};
}

// 计算报警标题栏页码文字的显示区域
QRectF alarmPageIndicatorRect(const QRectF &header) const
{
const QRectF next_rect = alarmNextPageRect(header);
@@ -776,6 +797,7 @@ private:
header.height()};
}

// 计算报警标题栏上一页按钮的点击区域
QRectF alarmPreviousPageRect(const QRectF &header) const
{
const QRectF indicator_rect = alarmPageIndicatorRect(header);
@@ -806,6 +828,7 @@ private:
address_height};
}

// 返回控件绑定地址的显示文字
QString bindingText() const
{
return control_.binding.has_value()
@@ -813,6 +836,7 @@ private:
: QString{};
}

// 读取控件文字颜色,无效或未配置时使用默认颜色
QColor configuredTextColor(const QColor &fallback) const
{
const auto property = control_.properties.find(
@@ -826,6 +850,7 @@ private:
return color.isValid() ? color : fallback;
}

// 将控件配置的字号、粗体和斜体应用到画笔
void applyConfiguredFont(QPainter *painter) const
{
if (painter == nullptr)
@@ -903,19 +928,33 @@ private:
int page_height_ = 0;
// 拖拽完成后回调 HmiEditorWidget 提交控件新位置
std::function<void(const std::string &, const QPointF &)> moved_;
// 运行态按钮按下和释放回调
std::function<void(const std::string &, HmiButtonEvent)> button_event_;
// 运行态数值输入激活回调
std::function<void(const std::string &)> numeric_input_activated_;
// 运行态页面跳转回调
std::function<void(const std::string &)> page_navigation_;
// 运行态报警确认回调
std::function<void(const std::string &)> alarm_acknowledge_;
// 当前报警列表控件显示的记录
std::vector<AlarmRecord> alarm_records_;
// 当前报警分页下标
std::size_t alarm_page_ = 0U;
// 当前是否允许选择和拖动图元
bool editing_enabled_ = true;
// 当前是否处于 HMI 运行态
bool runtime_active_ = false;
// 当前运行态是否允许写寄存器
bool runtime_write_enabled_ = false;
// 当前按钮启用条件是否满足
bool button_condition_enabled_ = true;
// 按钮是否处于鼠标悬停状态
bool button_hovered_ = false;
// 按钮是否处于按下状态
bool button_pressed_ = false;
// 页面跳转控件是否处于鼠标悬停状态
bool page_hovered_ = false;
// 页面跳转控件是否处于按下状态
bool page_pressed_ = false;
// 指示灯读取到的 M 位值
bool bit_value_ = false;
@@ -923,7 +962,9 @@ private:
RegisterNumericValue numeric_value_ = std::int16_t{0};
// 标记当前缓存值是否来自一次成功的运行时读取
bool has_runtime_value_ = false;
// 状态文本控件最近一次读取到的显示文字
QString status_text_;
// 状态文本最近一次读取是否成功
bool status_text_available_ = false;
};

@@ -935,6 +976,7 @@ HmiGraphicsItem *asHmiItem(QGraphicsItem *item)

} // namespace

// 创建 HMI 图形画布并连接场景选择信号
HmiEditorWidget::HmiEditorWidget(
HmiEditorService &editor_service,
HmiRuntimeService &runtime_service,
@@ -967,6 +1009,7 @@ void HmiEditorWidget::setPageId(const std::string &page_id)
reloadPage();
}

// 设置画布是否允许选择和拖动控件
void HmiEditorWidget::setEditingEnabled(bool enabled)
{
editing_enabled_ = enabled;
@@ -974,6 +1017,7 @@ void HmiEditorWidget::setEditingEnabled(bool enabled)
updateItemInteractions();
}

// 设置是否启用运行值刷新和运行态控件交互
void HmiEditorWidget::setRuntimeActive(bool active)
{
runtime_active_ = active;
@@ -994,6 +1038,7 @@ void HmiEditorWidget::setRuntimeActive(bool active)
updateItemInteractions();
}

// 设置运行态按钮和数值输入是否允许写入
void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled)
{
runtime_write_enabled_ = runtime_active_ && enabled;
@@ -1072,6 +1117,7 @@ void HmiEditorWidget::selectControl(const std::string &control_id)
}
}

// 选中指定的多个控件并滚动到第一个匹配项
void HmiEditorWidget::selectControls(
const std::vector<std::string> &control_ids)
{
@@ -1099,12 +1145,14 @@ void HmiEditorWidget::selectControls(
}
}

// 返回当前排序后的第一个选中控件标识
std::string HmiEditorWidget::selectedControlId() const
{
const std::vector<std::string> ids = selectedControlIds();
return ids.empty() ? std::string{} : ids.front();
}

// 按从上到下、从左到右的顺序返回选中控件标识
std::vector<std::string> HmiEditorWidget::selectedControlIds() const
{
std::vector<std::pair<QPointF, std::string>> positioned_ids;
@@ -1136,6 +1184,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const
return ids;
}

// 通过运行服务刷新当前页面所有控件的运行值
void HmiEditorWidget::refreshRuntimeValues()
{
// 运行态才读取寄存器;编辑态画布只显示工程配置,不显示缓存值
@@ -1182,23 +1231,27 @@ void HmiEditorWidget::refreshRuntimeValues()
}
}

// 视图尺寸变化后保持完整页面可见
void HmiEditorWidget::resizeEvent(QResizeEvent *event)
{
QGraphicsView::resizeEvent(event);
fitCurrentPage();
}

// 按宽高比将当前页面缩放到视图可见范围
void HmiEditorWidget::fitCurrentPage()
{
if (scene_->sceneRect().isEmpty())
{
return;
}
// KeepAspectRatio 防止页面和控件被非等比拉伸
fitInView(
scene_->sceneRect().adjusted(-24, -24, 24, 24),
Qt::KeepAspectRatio);
}

// 将编辑和运行权限同步到场景中的所有 HMI 图元
void HmiEditorWidget::updateItemInteractions()
{
// 遍历所有控件图元,统一设置flag
@@ -1213,11 +1266,13 @@ void HmiEditorWidget::updateItemInteractions()
}
}

// 将场景选择变化转换为控件标识信号
void HmiEditorWidget::handleSelectionChanged()
{
emit controlSelected(QString::fromStdString(selectedControlId()));
}

// 将图元最终位置取整后交给编辑服务保存
void HmiEditorWidget::handleControlMoved(
const std::string &control_id, const QPointF &position)
{
@@ -1242,6 +1297,7 @@ void HmiEditorWidget::handleControlMoved(
emit controlChanged(QString::fromStdString(control_id));
}

// 将运行态按钮事件交给运行服务处理
void HmiEditorWidget::handleButtonEvent(
const std::string &control_id, HmiButtonEvent event)
{
@@ -1274,6 +1330,7 @@ void HmiEditorWidget::handleButtonEvent(
refreshRuntimeValues();
}

// 读取当前值并根据数据类型弹出数值输入对话框
void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id)
{
if (!runtime_active_ || !runtime_write_enabled_)


+ 10
- 38
app/src/ui/hmi_editor_widget.h 查看文件

@@ -42,33 +42,17 @@ public:
AlarmService &alarm_service,
QWidget *parent = nullptr);

/**
* @brief 切换当前投影页面并重建画布图元
* @param page_id 要显示的页面标识,不存在时显示空场景
*/
/** 切换当前投影页面并重建画布图元 */
void setPageId(const std::string &page_id);
/**
* @brief 设置图元是否可移动
* @param enabled 为 false 时禁止编辑拖动,运行态交互由运行时开关单独控制
*/
/** 设置图元是否可以在编辑态选择和移动 */
void setEditingEnabled(bool enabled);
/**
* @brief 设置离线运行态寄存器刷新和控件交互是否启用
* @param active 为 true 时允许按钮和数值输入调用运行时服务
*/
/** 设置是否启用运行值刷新和运行态控件交互 */
void setRuntimeActive(bool active);
/** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */
void setRuntimeWriteEnabled(bool enabled);
/**
* @brief 使用当前页面模型完全重建场景
*
* 用于工程加载、控件属性更新后同步模型投影
*/
/** 使用当前页面模型完全重建场景 */
void reloadPage();
/**
* @brief 选中指定控件并滚动到可见区域
* @param control_id 待选中控件标识,不存在时不改变当前选择
*/
/** 选中指定控件并滚动到可见区域 */
void selectControl(const std::string &control_id);
/** 选中指定的多个控件并滚动到第一个可见控件 */
void selectControls(const std::vector<std::string> &control_ids);
@@ -76,33 +60,21 @@ public:
std::string selectedControlId() const;
/** 返回当前选中的多个控件标识 */
std::vector<std::string> selectedControlIds() const;
/**
* @brief 从统一寄存器仓库读取当前值并刷新画布显示
*
* 仅在运行时开关启用时执行,仓库读取失败的控件显示为无有效运行值
*/
/** 从统一寄存器仓库读取当前值并刷新画布显示 */
void refreshRuntimeValues();

signals:
/**
* @brief 选中控件变化时发出
* @param control_id 当前控件标识,没有选择时为空字符串
*/
/** 选中控件变化时发出,没有选择时标识为空 */
void controlSelected(const QString &control_id);
/**
* @brief 编辑服务成功更新控件后发出
* @param control_id 已更新控件标识
*/
/** 编辑服务成功更新控件后发出 */
void controlChanged(const QString &control_id);
/**
* @brief 编辑或运行时操作失败时发出
* @param message 可直接显示的错误信息
*/
/** 编辑或运行时操作失败时发出 */
void editorError(const QString &message);
/** 运行态请求跳转到指定 HMI 页面 */
void pageNavigationRequested(const QString &target_page_id);

protected:
/** 视图尺寸变化后重新缩放当前页面 */
void resizeEvent(QResizeEvent *event) override;

private:


+ 114
- 0
app/src/ui/logic_editor_widget.cpp 查看文件

@@ -49,11 +49,13 @@ const QColor kCommentColor(QStringLiteral("#16854f"));
const QColor kSelectionFill(QStringLiteral("#dfeef5"));
const QColor kSelectionBorder(QStringLiteral("#277da1"));

// 将寄存器地址转换为画布显示文本
QString registerAddressText(const RegisterAddress &address)
{
return QString::fromStdString(address.toString());
}

// 将常量或 D 寄存器操作数转换为指令文本
QString wordOperandText(const WordOperand &operand)
{
return operand.kind == WordOperandKind::Register
@@ -61,6 +63,7 @@ QString wordOperandText(const WordOperand &operand)
: QString::number(operand.constant);
}

// 将比较运算符转换为梯形图符号
QString comparisonText(ComparisonOperator comparison)
{
switch (comparison)
@@ -75,8 +78,10 @@ QString comparisonText(ComparisonOperator comparison)
return QStringLiteral("?");
}

// 将强类型节点配置转换为内嵌编辑器使用的指令文本
QString logicCommandText(const LogicNodeConfig &config)
{
// std::visit 在编译期选择节点配置分支,避免依赖字符串判断节点类型
return std::visit(
[](const auto &value) -> QString
{
@@ -131,12 +136,14 @@ QString logicCommandText(const LogicNodeConfig &config)
config);
}

// 从输入文本提取大写指令助记符作为补全前缀
QString commandCompletionPrefix(const QString &text)
{
return text.section(QRegularExpression(QStringLiteral("\\s+")), 0, 0)
.toUpper();
}

// 创建普通、导通或故障状态的梯形图画笔
QPen ladderPen(bool active, bool faulted = false)
{
QPen pen(faulted ? kFaultColor : active ? kActiveColor : kLadderColor);
@@ -146,12 +153,14 @@ QPen ladderPen(bool active, bool faulted = false)
return pen;
}

// 判断稳定标识是否已经存在于选择集合
bool containsId(
const std::vector<std::string> &ids, const std::string &candidate)
{
return std::find(ids.cbegin(), ids.cend(), candidate) != ids.cend();
}

// 判断网络行和列组成的网格位置是否已经选中
bool containsCell(
const std::vector<std::pair<std::string, int>> &cells,
const std::pair<std::string, int> &candidate)
@@ -164,15 +173,18 @@ struct GridRow
qreal top = 0.0;
};

// 绘制不参与业务命中的梯形图背景网格
class GridLayerItem final : public QGraphicsItem
{
public:
// 保存所有视觉行顶部坐标并置于场景底层
explicit GridLayerItem(std::vector<GridRow> rows)
: rows_(std::move(rows))
{
setZValue(0.0);
}

// 返回所有梯形图视觉行共同占用的场景范围
QRectF boundingRect() const override
{
if (rows_.empty())
@@ -186,11 +198,13 @@ public:
rows_.back().top + kRowHeight - rows_.front().top};
}

// 绘制白色行背景、外框和条件列分隔线
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override
{
painter->setRenderHint(QPainter::Antialiasing, false);
QPen grid_pen(kGridColor);
grid_pen.setWidthF(1.0);
// cosmetic 画笔在缩放时仍保持一个屏幕像素,网格不会变得过粗
grid_pen.setCosmetic(true);
painter->setPen(grid_pen);
for (const GridRow &row : rows_)
@@ -225,9 +239,11 @@ private:
std::vector<GridRow> rows_;
};

// 绘制一个条件单元格并保存供命中测试使用的业务元数据
class CellContentItem final : public QGraphicsItem
{
public:
// 保存单元格快照、轨迹状态和场景位置
CellContentItem(
const LadderCell &cell,
const std::string &rung_id,
@@ -241,6 +257,7 @@ public:
{
setPos(top_left);
setZValue(10.0);
// QGraphicsItem::data 只保存稳定定位信息,实际修改仍由服务层完成
setData(0, QStringLiteral("cell"));
setData(1, QString::fromStdString(rung_id));
setData(2, column);
@@ -252,11 +269,13 @@ public:
setData(7, output_active_);
}

// 返回单个条件格的固定绘制和命中范围
QRectF boundingRect() const override
{
return {0.0, 0.0, kCellWidth, kRowHeight};
}

// 根据单元格类型和运行轨迹绘制横线、触点或比较块
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override
{
if (cell_.kind == LadderCellKind::Gap)
@@ -285,6 +304,7 @@ public:
QFont font = painter->font();
font.setPointSizeF(8.5);
painter->setFont(font);
// 根据 variant 中的具体节点类型绘制对应符号
std::visit(
[&](const auto &config)
{
@@ -365,9 +385,11 @@ private:
bool faulted_ = false;
};

// 绘制一行输出槽中的线圈或数据指令
class OutputContentItem final : public QGraphicsItem
{
public:
// 保存输出节点快照和输入、符号轨迹状态
OutputContentItem(
const LadderRung &rung,
const QPointF &top_left,
@@ -380,6 +402,7 @@ public:
{
setPos(top_left);
setZValue(10.0);
// 输出槽元数据用于双击配置、选择和删除时还原业务位置
setData(0, QStringLiteral("output"));
setData(1, QString::fromStdString(rung.id));
setData(2, QString::fromStdString(
@@ -388,11 +411,13 @@ public:
setData(4, symbol_active_);
}

// 返回第 11 列输出槽的固定范围
QRectF boundingRect() const override
{
return {0.0, 0.0, kOutputWidth, kRowHeight};
}

// 根据输出类型绘制线圈、MOVE、ADD 或 SUB
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override
{
if (!output_.has_value())
@@ -523,9 +548,11 @@ private:
bool faulted_ = false;
};

// 绘制两条相邻视觉行之间的一段竖线
class VerticalConnectionItem final : public QGraphicsItem
{
public:
// 保存竖线业务定位和导通状态
VerticalConnectionItem(
const VerticalConnection &connection,
qreal x,
@@ -536,6 +563,7 @@ public:
{
setPos(x, top);
setZValue(20.0);
// 竖线元数据保留上下行和列边界,便于逐段选择和删除
setData(0, QStringLiteral("vertical"));
setData(1, QString::fromStdString(connection.id));
setData(2, QString::fromStdString(connection.upperRungId));
@@ -544,21 +572,25 @@ public:
setData(5, active_);
}

// 返回比可见线条更宽的基础命中矩形
QRectF boundingRect() const override
{
return {-7.0, 0.0, 14.0, height_};
}

// 将细竖线扩展为便于鼠标命中的路径
QPainterPath shape() const override
{
QPainterPath path;
path.moveTo(0.0, 0.0);
path.lineTo(0.0, height_);
QPainterPathStroker stroker;
// 可见竖线保持细线,命中形状单独扩宽到 12 像素
stroker.setWidth(12.0);
return stroker.createStroke(path);
}

// 根据运行轨迹绘制普通或导通竖线
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override
{
painter->setPen(ladderPen(active_));
@@ -570,9 +602,11 @@ private:
bool active_ = false;
};

// 在业务图元上方绘制当前显式选择范围
class SelectionOverlayItem final : public QGraphicsItem
{
public:
// 合并选择矩形并把覆盖层放到场景最上方
explicit SelectionOverlayItem(std::vector<QRectF> rectangles)
: rectangles_(std::move(rectangles))
{
@@ -584,11 +618,13 @@ public:
setZValue(100.0);
}

// 返回全部选择矩形合并后的外接范围
QRectF boundingRect() const override
{
return bounds_;
}

// 绘制半透明填充和虚线选择边框
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override
{
painter->setRenderHint(QPainter::Antialiasing, false);
@@ -613,6 +649,7 @@ private:

} // namespace

// 创建梯形图场景、橡皮框和带助记符补全的内嵌指令编辑器
LogicEditorWidget::LogicEditorWidget(
LogicEditorService &editor_service,
QWidget *parent)
@@ -629,6 +666,7 @@ LogicEditorWidget::LogicEditorWidget(
selection_band_ = new QRubberBand(QRubberBand::Rectangle, viewport());
selection_band_->hide();

// 输入框以 viewport 为父对象,缩放和滚动画布时仍可按屏幕坐标覆盖目标格
command_editor_ = new QLineEdit(viewport());
command_editor_->setObjectName(QStringLiteral("logicCommandInput"));
command_editor_->setPlaceholderText(
@@ -660,6 +698,7 @@ LogicEditorWidget::LogicEditorWidget(
command_completer_->setCompletionColumn(0);
command_completer_->setCaseSensitivity(Qt::CaseInsensitive);
command_completer_->setCompletionMode(QCompleter::PopupCompletion);
// 自定义三列表格弹窗,同时展示助记符、操作数格式和说明
auto *command_popup = new QTreeView;
command_popup->setRootIsDecorated(false);
command_popup->setItemsExpandable(false);
@@ -687,6 +726,7 @@ LogicEditorWidget::LogicEditorWidget(
command_editor_->installEventFilter(this);
}

// 切换当前控制逻辑并重建场景和选择状态
void LogicEditorWidget::setLogicId(const std::string &logic_id)
{
const bool logic_changed = logic_id_ != logic_id;
@@ -791,6 +831,7 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id)
rebuildScene();
}

// 切换画布编辑权限并关闭不再允许的临时交互
void LogicEditorWidget::setEditingEnabled(bool enabled)
{
clearGesture();
@@ -806,6 +847,7 @@ void LogicEditorWidget::setEditingEnabled(bool enabled)
rebuildScene();
}

// 切换鼠标选择、画线或删线模式
void LogicEditorWidget::setMouseWireMode(MouseWireMode mode)
{
clearGesture();
@@ -817,11 +859,13 @@ void LogicEditorWidget::setMouseWireMode(MouseWireMode mode)
? Qt::ArrowCursor : Qt::CrossCursor);
}

// 返回当前鼠标操作模式
LogicEditorWidget::MouseWireMode LogicEditorWidget::mouseWireMode() const
{
return mouse_wire_mode_;
}

// 保存运行轨迹和故障节点后重建只读场景
void LogicEditorWidget::setRuntimeTrace(
const LogicTraceSnapshot &trace,
const std::string &fault_node_id)
@@ -832,6 +876,7 @@ void LogicEditorWidget::setRuntimeTrace(
rebuildScene();
}

// 清除运行轨迹并恢复普通编辑绘制
void LogicEditorWidget::clearRuntimeTrace()
{
trace_.clear();
@@ -840,11 +885,13 @@ void LogicEditorWidget::clearRuntimeTrace()
rebuildScene();
}

// 使用当前逻辑模型重新创建场景内容
void LogicEditorWidget::reloadLogic()
{
setLogicId(logic_id_);
}

// 清除业务对象、橡皮框和内嵌指令选择
void LogicEditorWidget::clearSelection()
{
selected_rung_id_.clear();
@@ -862,6 +909,7 @@ void LogicEditorWidget::clearSelection()
emit nodeSelected(QString{});
}

// 选中并滚动到指定条件或输出节点
void LogicEditorWidget::selectNode(const std::string &node_id)
{
selected_node_ids_.clear();
@@ -906,6 +954,7 @@ void LogicEditorWidget::selectNode(const std::string &node_id)
emit nodeSelected(QString::fromStdString(node_id));
}

// 选中并滚动到服务层返回的语法错误位置
void LogicEditorWidget::focusSyntaxLocation(
const std::string &rung_id, int column)
{
@@ -973,16 +1022,19 @@ void LogicEditorWidget::focusSyntaxLocation(
setFocus(Qt::OtherFocusReason);
}

// 返回当前唯一选中的节点标识
std::string LogicEditorWidget::selectedNodeId() const
{
return selected_node_ids_.empty() ? std::string{} : selected_node_ids_.front();
}

// 返回当前显式选中的全部节点标识
std::vector<std::string> LogicEditorWidget::selectedNodeIds() const
{
return selected_node_ids_;
}

// 返回当前光标或整行选择对应的网络行标识
std::string LogicEditorWidget::selectedRungId() const
{
return !selected_rung_id_.empty()
@@ -990,6 +1042,7 @@ std::string LogicEditorWidget::selectedRungId() const
? selected_rung_id_ : std::string{};
}

// 判断当前显式选择能否复制为梯形图片段
bool LogicEditorWidget::hasCopyableSelection() const
{
return !selected_cells_.empty()
@@ -998,6 +1051,7 @@ bool LogicEditorWidget::hasCopyableSelection() const
|| !selected_row_ids_.empty();
}

// 将当前显式选择交给服务层生成剪贴板片段
LogicClipboardCopyResult LogicEditorWidget::copySelection() const
{
LogicSelectionCopyRequest selection;
@@ -1008,6 +1062,7 @@ LogicClipboardCopyResult LogicEditorWidget::copySelection() const
return editor_service_.copySelection(logic_id_, selection);
}

// 根据当前光标和整行选择生成粘贴目标
LogicPasteTarget LogicEditorWidget::pasteTarget() const
{
LogicPasteTarget target;
@@ -1022,6 +1077,7 @@ LogicPasteTarget LogicEditorWidget::pasteTarget() const
return target;
}

// 返回当前操作应使用的网络行标识
std::string LogicEditorWidget::currentRungId() const
{
if (!selected_rung_id_.empty()
@@ -1032,6 +1088,7 @@ std::string LogicEditorWidget::currentRungId() const
return editor_service_.firstRungId(logic_id_);
}

// 返回指定网络行在视觉布局中的序号
int LogicEditorWidget::rowAt(const std::string &rung_id) const
{
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
@@ -1049,6 +1106,7 @@ int LogicEditorWidget::rowAt(const std::string &rung_id) const
return -1;
}

// 从模型计算每条视觉行的顶部、中心和网络首行状态
void LogicEditorWidget::rebuildRowLayout(const ControlLogic &logic)
{
row_layouts_.clear();
@@ -1072,6 +1130,7 @@ void LogicEditorWidget::rebuildRowLayout(const ControlLogic &logic)
}
}

// 按稳定网络行标识查找场景布局
const LogicEditorWidget::RowLayout *LogicEditorWidget::layoutForRung(
const std::string &rung_id) const
{
@@ -1085,9 +1144,11 @@ const LogicEditorWidget::RowLayout *LogicEditorWidget::layoutForRung(
return found == row_layouts_.cend() ? nullptr : &*found;
}

// 将场景坐标解析为网格、输出、竖线或行号命中
LogicEditorWidget::Hit LogicEditorWidget::hitAt(
const QPointF &scene_position) const
{
// scene.items 按 Z 值从上到下返回,先识别竖线和透明命中层
const QList<QGraphicsItem *> items = scene_->items(scene_position);
const auto hit_for_type = [&items](const QString &wanted) -> Hit
{
@@ -1164,6 +1225,7 @@ LogicEditorWidget::Hit LogicEditorWidget::hitAt(
return {};
}

// 根据当前模型、选择和运行轨迹重建完整场景
void LogicEditorWidget::rebuildScene()
{
scene_->clear();
@@ -1426,6 +1488,7 @@ void LogicEditorWidget::rebuildScene()
}
}

// 清除节点、网格、输出、竖线和整行选择集合
void LogicEditorWidget::clearObjectSelection()
{
selected_node_ids_.clear();
@@ -1436,6 +1499,7 @@ void LogicEditorWidget::clearObjectSelection()
selected_vertical_connection_id_.clear();
}

// 移除与其他显式选择冲突的节点选择
void LogicEditorWidget::synchronizeSelectedNodes()
{
selected_node_ids_.clear();
@@ -1458,12 +1522,14 @@ void LogicEditorWidget::synchronizeSelectedNodes()
}
}

// 汇总选择状态并通知属性面板当前唯一节点
void LogicEditorWidget::notifySelectionChanged()
{
emit nodeSelected(selected_node_ids_.empty()
? QString{} : QString::fromStdString(selected_node_ids_.front()));
}

// 根据单次命中选择业务对象,并支持追加节点选择
void LogicEditorWidget::selectObject(
const Hit &hit, bool extend_node_selection)
{
@@ -1587,6 +1653,7 @@ void LogicEditorWidget::selectObject(
notifySelectionChanged();
}

// 将橡皮框覆盖的业务图元加入当前选择
void LogicEditorWidget::selectObjectsInBand(
const QRect &viewport_rect, bool extend_selection)
{
@@ -1603,6 +1670,7 @@ void LogicEditorWidget::selectObjectsInBand(
QPainterPath selection_path;
selection_path.addPolygon(scene_polygon);
selection_path.closeSubpath();
// 使用图元 shape 而不是外接矩形,细竖线的扩宽命中路径也能参与选择
const QList<QGraphicsItem *> items = scene_->items(
selection_path,
Qt::IntersectsItemShape,
@@ -1682,6 +1750,7 @@ void LogicEditorWidget::selectObjectsInBand(
notifySelectionChanged();
}

// 记录鼠标手势起点并准备选择或画线
void LogicEditorWidget::beginGesture(const Hit &hit)
{
if (!editing_enabled_ || hit.rungId.empty() || hit.output)
@@ -1695,6 +1764,7 @@ void LogicEditorWidget::beginGesture(const Hit &hit)
viewport()->update();
}

// 更新鼠标手势终点和前景预览位置
void LogicEditorWidget::updateGesture(
const Hit &hit, const QPointF &scene_position)
{
@@ -1708,6 +1778,7 @@ void LogicEditorWidget::updateGesture(
viewport()->update();
}

// 将完整横向或纵向手势一次性提交编辑服务
void LogicEditorWidget::finishGesture(const Hit &hit)
{
if (!gesture_active_)
@@ -1748,6 +1819,7 @@ void LogicEditorWidget::finishGesture(const Hit &hit)
{
last_rung = origin.lowerRungId;
}
// 竖线手势释放后一次提交经过的全部相邻行连接段
result = editor_service_.setVerticalConnectionRange(
logic_id_, first_rung, last_rung,
origin.column, connected);
@@ -1761,12 +1833,14 @@ void LogicEditorWidget::finishGesture(const Hit &hit)
else if (origin.column == hit.column
&& origin.rungId != hit.rungId)
{
// 从普通网格边界纵向拖动时创建或删除整段竖线范围
result = editor_service_.setVerticalConnectionRange(
logic_id_, origin.rungId, hit.rungId,
origin.column, connected);
}
else if (origin.rungId == hit.rungId)
{
// 同一行横向拖动时一次设置经过范围内的横线或空白格
result = editor_service_.setHorizontalWireRange(
logic_id_, origin.rungId,
origin.column, hit.column, connected);
@@ -1791,6 +1865,7 @@ void LogicEditorWidget::finishGesture(const Hit &hit)
}
}

// 清除尚未提交的手势状态并重绘前景层
void LogicEditorWidget::clearGesture()
{
if (!gesture_active_ && !gesture_scene_position_valid_)
@@ -1805,6 +1880,7 @@ void LogicEditorWidget::clearGesture()
viewport()->update();
}

// 在前景层绘制当前画线或删线手势的临时预览
void LogicEditorWidget::drawGesturePreview(QPainter *painter) const
{
if (!gesture_active_ || painter == nullptr
@@ -1919,6 +1995,7 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const
painter->restore();
}

// 在命中格或输出槽上显示内嵌指令输入框
void LogicEditorWidget::showCommandEditor(const Hit &hit)
{
if (!editing_enabled_ || hit.rungId.empty()
@@ -1974,6 +2051,7 @@ void LogicEditorWidget::showCommandEditor(const Hit &hit)
command_editor_->selectAll();
}

// 解析内嵌指令并将结果一次性提交编辑服务
void LogicEditorWidget::commitCommandInput()
{
if (command_editor_ == nullptr || !command_editor_->isVisible())
@@ -2025,6 +2103,7 @@ void LogicEditorWidget::commitCommandInput()
command_editor_->setFocus(Qt::OtherFocusReason);
}

// 隐藏内嵌指令输入框且不修改模型
void LogicEditorWidget::cancelCommandInput()
{
if (command_editor_ != nullptr)
@@ -2037,6 +2116,7 @@ void LogicEditorWidget::cancelCommandInput()
command_parallel_node_ids_.clear();
}

// 将内嵌输入框定位到指定场景中心对应的视口位置
void LogicEditorWidget::positionCommandInput(const QPointF &scene_center)
{
if (command_editor_ == nullptr)
@@ -2058,6 +2138,7 @@ void LogicEditorWidget::positionCommandInput(const QPointF &scene_center)
command_editor_->setGeometry(left, top, width, height);
}

// 查找内嵌命令目标在场景中的中心坐标
bool LogicEditorWidget::findCommandTargetCenter(
const LogicCommandTarget &target,
QPointF *scene_center) const
@@ -2118,6 +2199,7 @@ bool LogicEditorWidget::findCommandTargetCenter(
return true;
}

// 将服务层编辑光标转换为内嵌命令目标
LogicCommandTarget LogicEditorWidget::commandTargetForCursor(
const LogicEditCursor &cursor) const
{
@@ -2145,6 +2227,7 @@ LogicCommandTarget LogicEditorWidget::commandTargetForCursor(
return target;
}

// 根据当前网格、输出或节点选择生成条件插入光标
LogicEditCursor LogicEditorWidget::conditionInsertionCursor() const
{
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
@@ -2191,6 +2274,7 @@ LogicEditCursor LogicEditorWidget::conditionInsertionCursor() const
return {rung_id, ProjectLimits::kMaximumConditionColumns, true};
}

// 根据服务返回光标更新当前行列和场景选择
void LogicEditorWidget::moveToCursor(const LogicEditCursor &cursor)
{
clearObjectSelection();
@@ -2216,6 +2300,7 @@ void LogicEditorWidget::moveToCursor(const LogicEditCursor &cursor)
}
}

// 根据竖线编辑结果移动到下一视觉行的同一边界
void LogicEditorWidget::moveToVerticalTarget(
const LogicVerticalEditResult &result)
{
@@ -2259,6 +2344,7 @@ void LogicEditorWidget::moveToVerticalTarget(
24);
}

// 处理带下一光标的编辑结果并统一刷新场景
LogicEditorResult LogicEditorWidget::finishCursorEdit(
const LogicEditResult &result)
{
@@ -2272,11 +2358,13 @@ LogicEditorResult LogicEditorWidget::finishCursorEdit(
return result.edit;
}

// 将编辑失败结果转换为界面错误信号
void LogicEditorWidget::reportFailure(const LogicEditorResult &result)
{
emit editorError(QString::fromStdString(result.message));
}

// 根据鼠标模式开始橡皮框选择或画线手势
void LogicEditorWidget::mousePressEvent(QMouseEvent *event)
{
const Hit hit = hitAt(mapToScene(event->pos()));
@@ -2304,6 +2392,7 @@ void LogicEditorWidget::mousePressEvent(QMouseEvent *event)
event->accept();
}

// 更新橡皮框范围或画线手势预览
void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event)
{
if (mouse_wire_mode_ == MouseWireMode::Select && selection_pressed_)
@@ -2329,6 +2418,7 @@ void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event)
event->accept();
}

// 结束橡皮框选择或提交完整画线手势
void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event)
{
const Hit hit = hitAt(mapToScene(event->pos()));
@@ -2361,12 +2451,14 @@ void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event)
event->accept();
}

// 双击业务对象时打开内嵌指令编辑器
void LogicEditorWidget::mouseDoubleClickEvent(QMouseEvent *event)
{
showCommandEditor(hitAt(mapToScene(event->pos())));
event->accept();
}

// 处理退出画线模式和删除选择等键盘操作
void LogicEditorWidget::keyPressEvent(QKeyEvent *event)
{
if (event != nullptr
@@ -2381,6 +2473,7 @@ void LogicEditorWidget::keyPressEvent(QKeyEvent *event)
QGraphicsView::keyPressEvent(event);
}

// 在所有场景图元上方绘制尚未提交的手势预览
void LogicEditorWidget::drawForeground(
QPainter *painter, const QRectF &rect)
{
@@ -2388,6 +2481,7 @@ void LogicEditorWidget::drawForeground(
drawGesturePreview(painter);
}

// 视图尺寸变化后重新定位可见的内嵌输入框
void LogicEditorWidget::resizeEvent(QResizeEvent *event)
{
QGraphicsView::resizeEvent(event);
@@ -2401,6 +2495,7 @@ void LogicEditorWidget::resizeEvent(QResizeEvent *event)
}
}

// 滚动后同步内嵌输入框的视口位置
void LogicEditorWidget::scrollContentsBy(int dx, int dy)
{
QGraphicsView::scrollContentsBy(dx, dy);
@@ -2414,6 +2509,7 @@ void LogicEditorWidget::scrollContentsBy(int dx, int dy)
}
}

// 过滤内嵌输入框的 Escape、Tab 和补全弹窗按键
bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event)
{
if (watched == command_editor_ && event != nullptr)
@@ -2429,6 +2525,7 @@ bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event)
}
else if (event->type() == QEvent::FocusOut)
{
// 延迟到焦点切换完成后判断,点击补全弹窗不会误关输入框
QTimer::singleShot(
0,
this,
@@ -2456,8 +2553,10 @@ bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event)
return QGraphicsView::eventFilter(watched, event);
}

// 在当前逻辑末尾新增空白梯形图行
LogicEditorResult LogicEditorWidget::addRung()
{
// 将新建网络操作交给编辑服务,在当前逻辑组末尾添加空行
const LogicEditorResult result = editor_service_.addRung(logic_id_);
if (result.succeeded)
{
@@ -2478,9 +2577,11 @@ LogicEditorResult LogicEditorWidget::addRung()
return result;
}

// 在当前参考行之前或之后插入空白行
LogicEditorResult LogicEditorWidget::insertRung(bool after)
{
const std::string reference = selected_rung_id_;
// 没有参考行时追加末行,否则调用服务在选中行之前或之后插入
const LogicEditorResult result = reference.empty()
? editor_service_.addRung(logic_id_)
: editor_service_.insertRung(logic_id_, reference, after);
@@ -2503,6 +2604,7 @@ LogicEditorResult LogicEditorWidget::insertRung(bool after)
return result;
}

// 删除当前选中的梯形图行并刷新光标
LogicEditorResult LogicEditorWidget::deleteRung()
{
const std::string rung_id = selected_rung_id_;
@@ -2530,12 +2632,15 @@ LogicEditorResult LogicEditorWidget::deleteRung()
return result;
}

// 在当前条件光标位置添加节点并推进选择
LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config)
{
// 在当前条件格写入节点,并使用服务返回值推进编辑光标
return finishCursorEdit(editor_service_.applyConditionAndAdvance(
logic_id_, conditionInsertionCursor(), config, false));
}

// 使用当前节点范围创建并联支路
LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &config)
{
if (selected_node_ids_.empty() || selectedRungId().empty())
@@ -2562,6 +2667,7 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co
return result;
}

// 在当前条件格添加单格横线
LogicEditorResult LogicEditorWidget::addHorizontalWire()
{
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
@@ -2599,6 +2705,7 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire()
logic_id_, cursor));
}

// 在当前列边界添加竖线并推进到下一视觉行
LogicEditorResult LogicEditorWidget::addVerticalWire()
{
const std::string upper = selectedRungId();
@@ -2627,6 +2734,7 @@ LogicEditorResult LogicEditorWidget::addVerticalWire()
return result.edit;
}

// 删除当前条件格中的横线
LogicEditorResult LogicEditorWidget::deleteHorizontalWire()
{
const std::string rung_id = selectedRungId();
@@ -2643,6 +2751,7 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire()
return {false, LogicEditorError::InvalidOperation,
"请选择一格横线后再删除", {}};
}
// 删除单格横线时把该范围提交为断开状态,服务会将其恢复为 Gap
const LogicEditorResult result = editor_service_.setHorizontalWireRange(
logic_id_, rung_id, selected_column_, selected_column_, false);
if (result.succeeded)
@@ -2665,6 +2774,7 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire()
return result;
}

// 删除当前列边界上的竖线段
LogicEditorResult LogicEditorWidget::deleteVerticalWire()
{
if (selected_vertical_connection_id_.empty())
@@ -2699,9 +2809,11 @@ LogicEditorResult LogicEditorWidget::deleteVerticalWire()
return result;
}

// 设置当前网络输出并按网络关系推进光标
LogicEditorResult LogicEditorWidget::setOutput(
const LogicNodeConfig &config, bool configured)
{
// 把输出指令提交到第 11 列输出槽,并按网络关系推进光标
return finishCursorEdit(editor_service_.applyOutputAndAdvance(
logic_id_,
{currentRungId(), ProjectLimits::kMaximumConditionColumns, true},
@@ -2709,6 +2821,7 @@ LogicEditorResult LogicEditorWidget::setOutput(
configured));
}

// 将服务层剪贴板片段原子粘贴到当前目标
LogicClipboardPasteResult LogicEditorWidget::pasteClipboard(
const LogicClipboardFragment &fragment)
{
@@ -2774,6 +2887,7 @@ LogicClipboardPasteResult LogicEditorWidget::pasteClipboard(
return result;
}

// 原子删除当前显式选择的网格对象或完整行
LogicEditorResult LogicEditorWidget::deleteSelected()
{
LogicSelectionDeleteRequest selection;


+ 127
- 51
app/src/ui/logic_editor_widget.h 查看文件

@@ -22,155 +22,231 @@ class QPainter;
class QRubberBand;
class QResizeEvent;

// 将连续梯形图投影到场景,并把选择、画线和指令输入转交编辑服务
class LogicEditorWidget final : public QGraphicsView
{
Q_OBJECT
public:
enum class MouseWireMode { Select, Draw, Erase };
// 鼠标在画布上的选择、画线和删线模式
enum class MouseWireMode
{
Select, // 选择单元格、节点、输出、竖线或整行
Draw, // 拖动绘制连续横线或竖线
Erase // 拖动删除连续横线或竖线
};

// 创建梯形图画布并绑定编辑服务
explicit LogicEditorWidget(
LogicEditorService &editor_service,
QWidget *parent = nullptr);

// 切换当前投影的控制逻辑
void setLogicId(const std::string &logic_id);
// 设置画布是否允许编辑和选择
void setEditingEnabled(bool enabled);
// 切换鼠标选择、画线或删线模式
void setMouseWireMode(MouseWireMode mode);
// 返回当前鼠标操作模式
MouseWireMode mouseWireMode() const;
// 显示指定运行轨迹,并可突出故障节点
void setRuntimeTrace(
const LogicTraceSnapshot &trace,
const std::string &fault_node_id = {});
// 清除运行轨迹并恢复编辑态显示
void clearRuntimeTrace();
// 使用当前逻辑模型完全重建场景
void reloadLogic();
// 清除画布中的全部对象选择
void clearSelection();
// 选中并滚动到指定节点
void selectNode(const std::string &node_id);
/** 选中并滚动到语法错误位置,column 使用界面显示的 1 基列号 */
void focusSyntaxLocation(const std::string &rung_id, int column);
// 返回当前唯一选中的节点标识
std::string selectedNodeId() const;
// 返回当前选中的全部节点标识
std::vector<std::string> selectedNodeIds() const;
// 返回当前光标或整行选择对应的网络行标识
std::string selectedRungId() const;
// 返回当前选择是否可以生成梯形图剪贴板片段
bool hasCopyableSelection() const;
// 将当前显式选择复制为服务层剪贴板片段
LogicClipboardCopyResult copySelection() const;
// 根据当前选择计算粘贴目标位置
LogicPasteTarget pasteTarget() const;

// 在当前逻辑末尾新增一行
LogicEditorResult addRung();
// 在当前行之前或之后插入一行
LogicEditorResult insertRung(bool after);
// 删除当前选中的梯形图行
LogicEditorResult deleteRung();
// 在当前光标位置添加串联条件
LogicEditorResult addCondition(const LogicNodeConfig &config);
// 使用当前选择添加并联支路
LogicEditorResult addParallelBranch(const LogicNodeConfig &config);
// 在当前单元格添加横线
LogicEditorResult addHorizontalWire();
// 在当前边界添加竖线并推进到下一视觉行
LogicEditorResult addVerticalWire();
// 删除当前单元格中的横线
LogicEditorResult deleteHorizontalWire();
// 删除当前边界上的竖线
LogicEditorResult deleteVerticalWire();
// 设置当前网络输出并按服务结果推进光标
LogicEditorResult setOutput(
const LogicNodeConfig &config, bool configured = false);
// 将剪贴板片段粘贴到当前目标
LogicClipboardPasteResult pasteClipboard(
const LogicClipboardFragment &fragment);
// 原子删除当前显式选中的网格对象
LogicEditorResult deleteSelected();

signals:
// 当前唯一节点选择发生变化时通知属性面板
void nodeSelected(const QString &node_id);
// 梯形图模型成功修改后通知上层刷新
void graphChanged();
// 编辑操作失败时发出可直接显示的错误文本
void editorError(const QString &message);

protected:
// 根据鼠标按下位置开始选择或画线手势
void mousePressEvent(QMouseEvent *event) override;
// 更新橡皮框选择或画线预览
void mouseMoveEvent(QMouseEvent *event) override;
// 结束当前选择或画线手势并统一提交服务
void mouseReleaseEvent(QMouseEvent *event) override;
// 双击节点时打开对应指令配置
void mouseDoubleClickEvent(QMouseEvent *event) override;
// 处理删除、退出画线模式和内嵌指令快捷键
void keyPressEvent(QKeyEvent *event) override;
// 在场景前景层绘制尚未提交的画线预览
void drawForeground(QPainter *painter, const QRectF &rect) override;
// 视图尺寸变化后重新定位橡皮框和内嵌编辑器
void resizeEvent(QResizeEvent *event) override;
// 滚动画布后同步内嵌指令编辑器位置
void scrollContentsBy(int dx, int dy) override;
// 过滤内嵌指令输入框的提交和取消按键
bool eventFilter(QObject *watched, QEvent *event) override;

private:
// 保存一条视觉行在场景中的定位信息
struct RowLayout
{
std::string rungId;
int row = -1;
qreal gridTop = 0.0;
qreal centerY = 0.0;
qreal bottom = 0.0;
bool networkHead = false;
std::string rungId; // 对应的稳定网络行标识
int row = -1; // 在当前逻辑中的视觉行号
qreal gridTop = 0.0; // 条件网格顶部场景坐标
qreal centerY = 0.0; // 本行母线中心场景坐标
qreal bottom = 0.0; // 本行命中区域底部场景坐标
bool networkHead = false; // 是否为当前连通网络的首行
};

// 描述一次场景命中的业务对象和位置
struct Hit
{
std::string rungId;
int column = -1;
LadderCellKind cellKind = LadderCellKind::Gap;
bool output = false;
bool vertical = false;
bool boundary = false;
bool rowHeader = false;
std::string lowerRungId;
std::string objectId;
std::string rungId; // 命中位置所在的网络行
int column = -1; // 条件列或列边界编号
LadderCellKind cellKind = LadderCellKind::Gap; // 命中单元格的模型类型
bool output = false; // 是否命中第 11 列输出槽
bool vertical = false; // 是否命中已有竖线
bool boundary = false; // 是否命中可编辑的列边界
bool rowHeader = false; // 是否命中左侧整行选择区域
std::string lowerRungId; // 竖线命中时相邻下方行标识
std::string objectId; // 命中节点、单元格或竖线的稳定标识
};

// 将编辑服务失败结果转换为界面错误信号
void reportFailure(const LogicEditorResult &result);
// 返回当前光标或选择所在的网络行
std::string currentRungId() const;
// 将场景坐标解析为梯形图业务命中结果
Hit hitAt(const QPointF &scene_position) const;
// 记录鼠标手势起点和初始选择
void beginGesture(const Hit &hit);
// 更新鼠标手势终点和前景预览
void updateGesture(const Hit &hit, const QPointF &scene_position);
// 将完整鼠标手势一次性提交编辑服务
void finishGesture(const Hit &hit);
// 清除尚未提交的手势状态和预览
void clearGesture();
// 绘制当前横向或纵向画线手势预览
void drawGesturePreview(QPainter *painter) const;
// 在命中位置显示内嵌指令输入框
void showCommandEditor(const Hit &hit);
// 解析并提交内嵌指令文本
void commitCommandInput();
// 关闭内嵌指令输入且不修改模型
void cancelCommandInput();
// 将内嵌输入框定位到指定场景中心
void positionCommandInput(const QPointF &scene_center);
// 查找服务层命令目标在场景中的中心坐标
bool findCommandTargetCenter(
const LogicCommandTarget &target,
QPointF *scene_center) const;
// 将服务层编辑光标转换为命令输入目标
LogicCommandTarget commandTargetForCursor(
const LogicEditCursor &cursor) const;
// 根据当前选择生成条件插入光标
LogicEditCursor conditionInsertionCursor() const;
// 根据服务返回光标移动画布选择
void moveToCursor(const LogicEditCursor &cursor);
// 根据竖线编辑结果推进到下一视觉目标
void moveToVerticalTarget(const LogicVerticalEditResult &result);
// 处理带下一光标的编辑结果并刷新场景
LogicEditorResult finishCursorEdit(const LogicEditResult &result);
// 根据当前逻辑模型重新创建全部图元和布局缓存
void rebuildScene();
// 选中单次命中的对象并处理扩展节点选择
void selectObject(const Hit &hit, bool extend_node_selection = false);
// 将视口橡皮框覆盖的图元加入显式选择
void selectObjectsInBand(const QRect &viewport_rect, bool extend_selection);
// 清除所有业务对象选择状态
void clearObjectSelection();
// 同步节点选择和其他类型选择之间的互斥关系
void synchronizeSelectedNodes();
// 汇总当前选择并通知属性面板
void notifySelectionChanged();
// 从控制逻辑模型重建视觉行定位缓存
void rebuildRowLayout(const ControlLogic &logic);
// 按网络行标识查找视觉布局
const RowLayout *layoutForRung(const std::string &rung_id) const;
// 返回指定网络行的视觉序号
int rowAt(const std::string &rung_id) const;

LogicEditorService &editor_service_;
LogicCommandService command_service_;
QGraphicsScene *scene_ = nullptr;
std::string logic_id_;
std::string selected_rung_id_;
std::vector<std::string> selected_node_ids_;
std::vector<std::pair<std::string, int>> selected_cells_;
std::vector<std::string> selected_output_rung_ids_;
std::vector<std::string> selected_vertical_connection_ids_;
std::vector<std::string> selected_row_ids_;
std::vector<RowLayout> row_layouts_;
LogicTraceSnapshot trace_;
std::string fault_node_id_;
bool runtime_trace_enabled_ = false;
bool editing_enabled_ = true;
MouseWireMode mouse_wire_mode_ = MouseWireMode::Select;
int selected_column_ = -1;
bool selected_cell_ = false;
bool selected_output_ = false;
bool selected_boundary_ = false;
std::string selected_vertical_connection_id_;
QRubberBand *selection_band_ = nullptr;
QPoint selection_origin_;
bool selection_pressed_ = false;
bool selection_dragging_ = false;
Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier;
bool gesture_active_ = false;
Hit gesture_origin_;
Hit gesture_current_;
QPointF gesture_scene_position_;
bool gesture_scene_position_valid_ = false;
QLineEdit *command_editor_ = nullptr;
QCompleter *command_completer_ = nullptr;
LogicCommandTarget command_target_;
std::vector<std::string> command_parallel_node_ids_;
LogicEditorService &editor_service_; // 不拥有的梯形图编辑服务
LogicCommandService command_service_; // 解析内嵌指令文本并提交编辑命令
QGraphicsScene *scene_ = nullptr; // 持有梯形图图元的场景
std::string logic_id_; // 当前投影的控制逻辑标识
std::string selected_rung_id_; // 当前光标或对象所在的网络行
std::vector<std::string> selected_node_ids_; // 显式选中的条件和输出节点
std::vector<std::pair<std::string, int>> selected_cells_; // 显式选中的网格位置
std::vector<std::string> selected_output_rung_ids_; // 显式选中的输出槽所在行
std::vector<std::string> selected_vertical_connection_ids_; // 显式选中的竖线
std::vector<std::string> selected_row_ids_; // 通过行号区域选中的完整行
std::vector<RowLayout> row_layouts_; // 当前场景的视觉行定位缓存
LogicTraceSnapshot trace_; // 最近一次显示的本地逻辑轨迹
std::string fault_node_id_; // 需要突出显示的执行故障节点
bool runtime_trace_enabled_ = false; // 是否以只读运行轨迹方式绘制
bool editing_enabled_ = true; // 是否允许编辑和业务对象选择
MouseWireMode mouse_wire_mode_ = MouseWireMode::Select; // 当前鼠标操作模式
int selected_column_ = -1; // 当前光标所在的条件列或边界
bool selected_cell_ = false; // 当前光标是否明确选中单元格
bool selected_output_ = false; // 当前光标是否明确选中输出槽
bool selected_boundary_ = false; // 当前光标是否明确选中列边界
std::string selected_vertical_connection_id_; // 当前光标命中的竖线标识
QRubberBand *selection_band_ = nullptr; // 视口坐标下的橡皮框控件
QPoint selection_origin_; // 橡皮框选择的视口起点
bool selection_pressed_ = false; // 是否正在等待进入橡皮框拖动
bool selection_dragging_ = false; // 是否已经形成有效橡皮框拖动
Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; // 选择开始时的修饰键
bool gesture_active_ = false; // 是否存在尚未提交的画线手势
Hit gesture_origin_; // 当前画线手势的业务起点
Hit gesture_current_; // 当前画线手势的业务终点
QPointF gesture_scene_position_; // 鼠标当前场景坐标
bool gesture_scene_position_valid_ = false; // 当前场景坐标是否可用于绘制预览
QLineEdit *command_editor_ = nullptr; // 覆盖在视图上的内嵌指令输入框
QCompleter *command_completer_ = nullptr; // 指令助记符补全器
LogicCommandTarget command_target_; // 当前内嵌输入对应的服务层目标
std::vector<std::string> command_parallel_node_ids_; // 并联命令关联的节点范围
};

+ 11
- 0
app/src/ui/logic_instruction_dialog.cpp 查看文件

@@ -14,6 +14,7 @@ namespace {
constexpr int kConstantOperandData = 0;
constexpr int kRegisterOperandData = 1;

// 填充字操作数的常量和 D 寄存器类型选项
void configureOperandCombo(QComboBox *combo)
{
combo->clear();
@@ -23,6 +24,7 @@ void configureOperandCombo(QComboBox *combo)

} // namespace

// 加载原节点配置并连接操作数类型变化信号
LogicInstructionDialog::LogicInstructionDialog(
const LogicNodeConfig &config,
QWidget *parent)
@@ -40,6 +42,7 @@ LogicInstructionDialog::LogicInstructionDialog(
ui_->leftOperandKindComboBox,
ui_->rightOperandKindComboBox})
{
// qOverload 明确选择带索引参数的重载信号
connect(
combo,
qOverload<int>(&QComboBox::currentIndexChanged),
@@ -58,10 +61,13 @@ LogicInstructionDialog::LogicInstructionDialog(
updateOperandRanges();
}

// 释放 Designer 界面对象
LogicInstructionDialog::~LogicInstructionDialog() = default;

// 将当前表单转换为 MOVE、ADD 或 SUB 的强类型配置
LogicNodeConfig LogicInstructionDialog::config() const
{
// variant 区分 MOVE 和算术指令,分别组装对应的强类型配置
if (std::holds_alternative<MoveNodeConfig>(original_config_))
{
return MoveNodeConfig{
@@ -80,6 +86,7 @@ LogicNodeConfig LogicInstructionDialog::config() const
ui_->destinationAddressSpinBox->value()}};
}

// 使用 MOVE 配置初始化单源操作数表单
void LogicInstructionDialog::configureMove(const MoveNodeConfig &config)
{
setWindowTitle(tr("配置 MOVE"));
@@ -95,6 +102,7 @@ void LogicInstructionDialog::configureMove(const MoveNodeConfig &config)
ui_->destinationAddressSpinBox->setValue(config.destination.index());
}

// 使用 ADD 或 SUB 配置初始化双操作数表单
void LogicInstructionDialog::configureArithmetic(const ArithmeticNodeConfig &config)
{
const bool is_add = config.operation == ArithmeticOperation::Add;
@@ -116,6 +124,7 @@ void LogicInstructionDialog::configureArithmetic(const ArithmeticNodeConfig &con
ui_->destinationAddressSpinBox->setValue(config.destination.index());
}

// 根据常量或 D 寄存器类型调整输入前缀和数值范围
void LogicInstructionDialog::updateOperandRanges()
{
const auto update = [](const QComboBox *combo, QSpinBox *spin)
@@ -134,6 +143,7 @@ void LogicInstructionDialog::updateOperandRanges()
update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox);
}

// 将领域字操作数写入类型下拉框和数值输入框
void LogicInstructionDialog::setOperand(
QComboBox *kind_combo,
QSpinBox *value_spin,
@@ -147,6 +157,7 @@ void LogicInstructionDialog::setOperand(
? operand.address.index() : operand.constant);
}

// 从类型下拉框和数值输入框读取领域字操作数
WordOperand LogicInstructionDialog::operandFrom(
const QComboBox *kind_combo,
const QSpinBox *value_spin)


+ 181
- 32
app/src/ui/main_window.cpp
文件差异内容过多而无法显示
查看文件


+ 26
- 23
app/src/ui/main_window.h 查看文件

@@ -14,6 +14,7 @@
#include "services/plc_communication_gateway.h"
#include "services/application_settings.h"
#include "services/logic_editor_service.h"
#include "services/user_runtime_plc_recovery_policy.h"

#include <QMainWindow>

@@ -237,25 +238,15 @@ private:
const QString &message,
const std::optional<LogicSyntaxLocation> &location = std::nullopt);

/**
* @brief 请求服务层切换模式并同步界面状态
* @param requested_mode 目标应用模式
*
* 失败时恢复与服务层当前状态一致的工具栏选项
*/
/** 请求服务层切换模式,失败时恢复与实际状态一致的工具栏选项 */
bool requestMode(ApplicationMode requested_mode);

/**
* @brief 根据服务层模式策略更新编辑权限和状态反馈
* @param message 要显示在状态栏和输出区的模式结果消息
*/
/** 根据服务层模式策略更新编辑权限和状态反馈 */
void updateModeUi(const QString &message);
/** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */
void updateSimulationUi(bool report_fault);

/**
* @brief 将互斥模式动作同步到服务层当前状态
*/
/** 将互斥模式动作同步到服务层当前状态 */
void restoreCurrentModeAction();
/** 完成主窗口初始化和首次界面刷新 */
void initializeUi();
@@ -263,20 +254,18 @@ private:
void initializeUserRuntime();
/** 用户运行版完成 PLC 首读后自动进入真机运行 */
void enterUserRuntimeAfterInitialRead();
/** 用户运行版在串口或 PLC 通信故障后自动尝试重连 */
/** 用户运行版在本地串口断开后自动尝试重新打开串口 */
void attemptUserRuntimeReconnect();
/** 用户运行版在一次 PLC 通信故障周期内只显示一次提示 */
void reportUserRuntimePlcFailure(const std::string &error);
/** 刷新编辑态数据监控的模式、权限和当前值 */
void refreshDataMonitorUi();

/** Qt Designer 生成的主窗口界面对象 */
std::unique_ptr<Ui::MainWindow> ui_;
/**
* @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期
*/
/** 非拥有的运行模式服务依赖,由应用入口保证生命周期 */
RuntimeModeService &runtime_mode_service_;
/**
* @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期
*/
/** 非拥有的工程服务依赖,由应用入口保证生命周期 */
ProjectService &project_service_;
/** 非拥有的 HMI 编辑服务依赖 */
HmiEditorService &hmi_editor_service_;
@@ -310,13 +299,19 @@ private:
std::unique_ptr<RuntimePanelController> runtime_panel_controller_;
/** 运行模式互斥动作组 */
QActionGroup *mode_action_group_ = nullptr;
/** HMI 控件布局菜单按钮和对齐动作 */
/** HMI 控件布局菜单按钮 */
QToolButton *hmi_layout_button_ = nullptr;
/** HMI 控件左对齐动作 */
QAction *align_left_action_ = nullptr;
/** HMI 控件水平居中动作 */
QAction *align_horizontal_center_action_ = nullptr;
/** HMI 控件右对齐动作 */
QAction *align_right_action_ = nullptr;
/** HMI 控件顶部对齐动作 */
QAction *align_top_action_ = nullptr;
/** HMI 控件垂直居中动作 */
QAction *align_vertical_center_action_ = nullptr;
/** HMI 控件底部对齐动作 */
QAction *align_bottom_action_ = nullptr;
/** 主界面的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr;
@@ -326,6 +321,7 @@ private:
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 主窗口中的数据监控编辑页 */
FreeMonitorWidget *data_monitor_widget_ = nullptr;
/** 定时刷新数据监控页面的计时器 */
QTimer *data_monitor_refresh_timer_ = nullptr;
/** 显示当前运行模式的状态标签 */
QLabel *mode_status_label_ = nullptr;
@@ -345,22 +341,29 @@ private:
std::string current_logic_id_;
/** 是否已经安排了待处理的 PLC 状态刷新 */
bool plc_status_update_pending_ = false;
/** 运行版最近一次已提示的 PLC 错误,避免同一故障重复弹窗 */
std::string last_user_runtime_plc_error_;
/** 用户运行版串口重连和通信故障提示周期策略 */
UserRuntimePlcRecoveryPolicy user_runtime_plc_recovery_policy_;
/** 用户运行版自动重连定时器 */
QTimer *user_runtime_reconnect_timer_ = nullptr;
/** HMI 控件剪贴板内容和连续粘贴次数 */
struct HmiClipboardData
{
/** 复制的 HMI 控件 */
std::vector<HmiControl> controls;
/** 当前剪贴板内容已经粘贴的次数 */
int pasteCount = 0;
};
/** 梯形图剪贴板内容 */
struct LogicClipboardData
{
/** 复制的梯形图片段 */
LogicClipboardFragment fragment;
};
/** 编辑器共用的空、HMI 和梯形图剪贴板类型 */
using EditorClipboard = std::variant<
std::monostate,
HmiClipboardData,
LogicClipboardData>;
/** 当前编辑器剪贴板内容 */
EditorClipboard editor_clipboard_;
};

+ 16
- 0
app/src/ui/plc_connection_dialog.cpp 查看文件

@@ -17,6 +17,7 @@

namespace {

// 为 Designer 中已有的下拉选项绑定实际整数值
void setItemData(QComboBox *combo_box, const std::initializer_list<int> &values)
{
int index = 0;
@@ -27,6 +28,7 @@ void setItemData(QComboBox *combo_box, const std::initializer_list<int> &values)
}
}

// 按业务值选中下拉项,找不到时保持原选择
void selectData(QComboBox *combo_box, int value)
{
const int index = combo_box->findData(value);
@@ -38,6 +40,7 @@ void selectData(QComboBox *combo_box, int value)

} // namespace

// 加载串口配置并绑定异步自动搜索回调
PlcConnectionDialog::PlcConnectionDialog(
const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
@@ -76,6 +79,7 @@ PlcConnectionDialog::PlcConnectionDialog(
{
refreshSerialPorts(ui_->serialPortComboBox->currentText().trimmed());
});
// 点击自动搜索后进入候选串口逐项探测入口
connect(
ui_->autoSearchButton, &QPushButton::clicked,
this, &PlcConnectionDialog::toggleAutomaticDiscovery);
@@ -86,16 +90,19 @@ PlcConnectionDialog::PlcConnectionDialog(
},
[this](const PlcDiscoveryOutcome &outcome)
{
// 搜索服务结束后统一处理找到、未找到和取消三种结果
handleDiscoveryFinished(outcome);
});
}

// 解绑异步回调并取消尚未结束的自动搜索
PlcConnectionDialog::~PlcConnectionDialog()
{
discovery_gateway_.setCallbacks({}, {});
discovery_gateway_.cancelDiscovery();
}

// 将当前表单转换为 PLC 串口和轮询配置
PlcSerialConfiguration PlcConnectionDialog::configuration() const
{
PlcSerialConfiguration result;
@@ -112,6 +119,7 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const
return result;
}

// 重新枚举可用串口并尽量恢复原选择
void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port)
{
ui_->serialPortComboBox->clear();
@@ -136,6 +144,7 @@ void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port)
}
}

// 开始自动搜索,搜索中再次触发则请求取消
void PlcConnectionDialog::toggleAutomaticDiscovery()
{
if (discovery_gateway_.isDiscovering())
@@ -145,6 +154,7 @@ void PlcConnectionDialog::toggleAutomaticDiscovery()
discovery_gateway_.cancelDiscovery();
return;
}
// 把当前表单配置作为首选组合,交给搜索服务异步逐项探测
const PlcCommunicationResult result = discovery_gateway_.startDiscovery(
configuration());
if (!result.succeeded)
@@ -161,6 +171,7 @@ void PlcConnectionDialog::toggleAutomaticDiscovery()
setDiscoveryUiActive(true);
}

// 显示当前搜索组合和总体尝试进度
void PlcConnectionDialog::showDiscoveryProgress(
const PlcDiscoveryProgress &progress)
{
@@ -184,6 +195,7 @@ void PlcConnectionDialog::showDiscoveryProgress(
.arg(progress.totalAttempts));
}

// 处理取消、未找到或成功找到 PLC 的搜索结果
void PlcConnectionDialog::handleDiscoveryFinished(
const PlcDiscoveryOutcome &outcome)
{
@@ -202,11 +214,14 @@ void PlcConnectionDialog::handleDiscoveryFinished(
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
// 命中 PLC 后把实际可用参数完整填回表单
applyConfiguration(outcome.configuration);
ui_->discoveryStatusLabel->setText(tr("已找到 PLC,正在连接"));
// 延迟到当前回调返回后再接受对话框,避免在网关通知栈中销毁界面
QTimer::singleShot(0, this, &QDialog::accept);
}

// 搜索期间锁定配置输入,只保留停止搜索入口
void PlcConnectionDialog::setDiscoveryUiActive(bool active)
{
ui_->discoveryProgressBar->setVisible(active);
@@ -229,6 +244,7 @@ void PlcConnectionDialog::setDiscoveryUiActive(bool active)
active ? QStyle::SP_BrowserStop : QStyle::SP_BrowserReload));
}

// 将自动搜索命中的配置完整回填到表单
void PlcConnectionDialog::applyConfiguration(
const PlcSerialConfiguration &configuration)
{


+ 16
- 0
app/src/ui/project_workspace_controller.cpp 查看文件

@@ -28,17 +28,20 @@ namespace {
constexpr int kProjectTreeKindRole = Qt::UserRole;
constexpr int kProjectTreeIdRole = Qt::UserRole + 1;

// 将 UTF-8 标准字符串转换为 Qt 文本
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 将 Qt 文本转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

// 显示必填名称对话框并只在非空时允许确认
bool requestRequiredName(
QWidget &parent,
const QString &title,
@@ -116,6 +119,7 @@ ProjectWorkspaceController::ProjectWorkspaceController(
{
}

// 创建工程树动作并连接选择和操作信号
void ProjectWorkspaceController::configure()
{
ui_.projectTree->setContextMenuPolicy(Qt::ActionsContextMenu);
@@ -174,6 +178,7 @@ void ProjectWorkspaceController::configure()
updateActions();
}

// 根据当前工程重建树节点和两类编辑器投影
void ProjectWorkspaceController::refresh()
{
if (hmi_editor_service_.findPage(current_hmi_page_id_) == nullptr)
@@ -198,6 +203,7 @@ void ProjectWorkspaceController::refresh()
}
runtime_mode_service_.refreshPlcPollAddresses();

// 重建期间屏蔽选择信号,避免树内容尚未完整时触发编辑器切换
const QSignalBlocker blocker(ui_.projectTree);
ui_.projectTree->clear();
auto *hmi_root = new QTreeWidgetItem(ui_.projectTree, {QObject::tr("HMI 页面")});
@@ -256,6 +262,7 @@ void ProjectWorkspaceController::refresh()
updateActions();
}

// 将工程树选择同步到当前页面、逻辑和属性面板
void ProjectWorkspaceController::handleSelectionChanged()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
@@ -291,6 +298,7 @@ void ProjectWorkspaceController::handleSelectionChanged()
updateActions();
}

// 根据当前树节点类型和位置刷新动作可用状态
void ProjectWorkspaceController::updateActions()
{
if (add_page_action_ == nullptr)
@@ -321,6 +329,7 @@ void ProjectWorkspaceController::updateActions()
}
}

// 请求服务层新增 HMI 页面并刷新选择
void ProjectWorkspaceController::addPage()
{
QString name;
@@ -343,6 +352,7 @@ void ProjectWorkspaceController::addPage()
edit_state_changed_();
}

// 请求服务层新增控制逻辑并刷新选择
void ProjectWorkspaceController::addLogic()
{
QString name;
@@ -365,6 +375,7 @@ void ProjectWorkspaceController::addLogic()
edit_state_changed_();
}

// 校验新名称后重命名当前页面或控制逻辑
void ProjectWorkspaceController::renameSelectedItem()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
@@ -419,6 +430,7 @@ void ProjectWorkspaceController::renameSelectedItem()
edit_state_changed_();
}

// 确认后删除当前页面或控制逻辑
void ProjectWorkspaceController::deleteSelectedItem()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
@@ -457,6 +469,7 @@ void ProjectWorkspaceController::deleteSelectedItem()
edit_state_changed_();
}

// 按指定方向移动当前页面或控制逻辑
void ProjectWorkspaceController::moveSelectedItem(int offset)
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
@@ -493,6 +506,7 @@ void ProjectWorkspaceController::moveSelectedItem(int offset)
edit_state_changed_();
}

// 将当前 HMI 页面设置为工程初始页面
void ProjectWorkspaceController::setSelectedPageAsInitial()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
@@ -513,6 +527,7 @@ void ProjectWorkspaceController::setSelectedPageAsInitial()
edit_state_changed_();
}

// 切换当前控制逻辑的启用状态
void ProjectWorkspaceController::toggleSelectedLogicEnabled()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
@@ -539,6 +554,7 @@ void ProjectWorkspaceController::toggleSelectedLogicEnabled()
edit_state_changed_();
}

// 将服务层 UTF-8 错误统一报告给主窗口
void ProjectWorkspaceController::reportFailure(
const QString &action, const std::string &message) const
{


+ 22
- 0
app/src/ui/property_panel_controller.cpp 查看文件

@@ -31,17 +31,20 @@

namespace {

// 将 UTF-8 标准字符串转换为 Qt 文本
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 将 Qt 文本转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

// 返回不同 HMI 控件类型的新建默认字号
int defaultFontPointSize(HmiControlType type)
{
int point_size = QApplication::font().pointSize();
@@ -87,6 +90,7 @@ PropertyPanelController::PropertyPanelController(
{
}

// 配置属性输入范围并连接提交、颜色和专用配置入口
void PropertyPanelController::configure()
{
ui_.pageWidthSpinBox->setRange(
@@ -135,6 +139,7 @@ void PropertyPanelController::configure()
showControlProperties({});
}

// 绑定两个画布的选择、修改和错误信号
void PropertyPanelController::bindEditorWidgets(
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget)
@@ -174,6 +179,7 @@ void PropertyPanelController::bindEditorWidgets(
});
}

// 将指定 HMI 控件的当前属性加载到表单
void PropertyPanelController::showControlProperties(const std::string &control_id)
{
if (control_id.empty())
@@ -285,6 +291,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.configureButtonExtensionButton->setEnabled(is_button);
if (is_button)
{
// variant 保存两种按钮启用条件,按实际类型显示对应摘要
if (!control->buttonEnableCondition.has_value())
{
ui_.configureButtonExtensionButton->setText(QObject::tr("未设置"));
@@ -405,6 +412,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
&& font_italic->second == "true");
}

// 将指定 HMI 页面尺寸加载到属性表单
void PropertyPanelController::showPageProperties(const std::string &page_id)
{
ui_.propertyStack->setCurrentWidget(ui_.pagePropertiesPage);
@@ -432,6 +440,7 @@ void PropertyPanelController::showPageProperties(const std::string &page_id)
ui_.pageHeightSpinBox->setValue(page->height);
}

// 将指定梯形图节点配置加载到属性表单
void PropertyPanelController::showLogicNodeProperties(const std::string &node_id)
{
ui_.propertyStack->setCurrentWidget(ui_.logicPropertiesPage);
@@ -465,6 +474,7 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicModeComboBox->clear();
ui_.logicComparisonComboBox->setEnabled(false);
ui_.logicValueSpinBox->setEnabled(false);
// 节点配置使用 variant,按节点种类恢复不同的属性控件
if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("触点"));
@@ -548,6 +558,7 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
}
}

// 使用启动默认值创建指定类型的 HMI 控件
void PropertyPanelController::addHmiControl(HmiControlType type)
{
const HmiEditorResult result = hmi_editor_service_.addControl(
@@ -568,6 +579,7 @@ void PropertyPanelController::addHmiControl(HmiControlType type)
status_reporter_(QObject::tr("已添加%1").arg(display_name), 3000);
}

// 删除当前选中的 HMI 控件并清理选择
void PropertyPanelController::deleteSelectedControl()
{
if (hmi_editor_widget_ == nullptr)
@@ -593,6 +605,7 @@ void PropertyPanelController::deleteSelectedControl()
refresh_project_ui_();
}

// 读取 HMI 属性表单并通过编辑服务原子提交
void PropertyPanelController::applySelectedControlProperties()
{
const HmiControl *old_control = hmi_editor_service_.findControl(
@@ -676,6 +689,7 @@ void PropertyPanelController::applySelectedControlProperties()
status_reporter_(QObject::tr("控件属性已更新"), 3000);
}

// 读取页面尺寸表单并通过编辑服务原子提交
void PropertyPanelController::applySelectedPageProperties()
{
const std::string page_id = current_page_id_();
@@ -701,6 +715,7 @@ void PropertyPanelController::applySelectedPageProperties()
status_reporter_(QObject::tr("页面尺寸已更新"), 3000);
}

// 打开颜色选择器并把有效颜色写回十六进制文本
void PropertyPanelController::chooseTextColor()
{
QColor initial_color(ui_.textColorEdit->text());
@@ -719,6 +734,7 @@ void PropertyPanelController::chooseTextColor()
}
}

// 打开状态文本专用对话框并提交完整控件配置
void PropertyPanelController::configureStatusText()
{
const HmiControl *old_control = hmi_editor_service_.findControl(
@@ -746,6 +762,7 @@ void PropertyPanelController::configureStatusText()
status_reporter_(QObject::tr("状态文本配置已更新"), 3000);
}

// 打开按钮启用条件对话框并提交完整控件配置
void PropertyPanelController::configureButtonExtension()
{
const HmiControl *old_control = hmi_editor_service_.findControl(
@@ -775,6 +792,7 @@ void PropertyPanelController::configureButtonExtension()
status_reporter_(QObject::tr("按钮扩展配置已更新"), 3000);
}

// 读取梯形图节点表单并提交强类型配置
void PropertyPanelController::applySelectedLogicNodeProperties()
{
const LogicNode *node = logic_editor_service_.findNode(
@@ -785,6 +803,7 @@ void PropertyPanelController::applySelectedLogicNodeProperties()
}

LogicNodeConfig config = node->config;
// 保留节点原有种类,只替换界面中允许编辑的字段
if (std::holds_alternative<ContactNodeConfig>(config))
{
config = ContactNodeConfig{
@@ -835,16 +854,19 @@ void PropertyPanelController::applySelectedLogicNodeProperties()
status_reporter_(QObject::tr("逻辑节点属性已更新,节点已配置"), 3000);
}

// 将 HMI 编辑错误显示到统一结果和状态入口
void PropertyPanelController::handleHmiEditorError(const QString &message) const
{
status_reporter_(message, 5000);
}

// 将梯形图编辑错误显示到统一结果和状态入口
void PropertyPanelController::handleLogicEditorError(const QString &message) const
{
status_reporter_(message, 5000);
}

// 将服务层 UTF-8 错误统一报告给主窗口
void PropertyPanelController::reportFailure(
const QString &action, const std::string &message) const
{


+ 11
- 0
app/src/ui/register_comment_dialog.cpp 查看文件

@@ -11,11 +11,13 @@

namespace {

// 将 UTF-8 标准字符串转换为 Qt 文本
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 将 Qt 文本转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
@@ -24,6 +26,7 @@ std::string toUtf8(const QString &value)

} // namespace

// 初始化寄存器注释表格并绑定保存、删除和选择事件
RegisterCommentDialog::RegisterCommentDialog(
RegisterCommentService &service,
QWidget *parent)
@@ -49,8 +52,10 @@ RegisterCommentDialog::RegisterCommentDialog(
reloadComments();
}

// 释放 Designer 界面对象
RegisterCommentDialog::~RegisterCommentDialog() = default;

// 重新加载注释列表并尽量恢复指定地址的选择
void RegisterCommentDialog::reloadComments(
const RegisterAddress *selected_address)
{
@@ -63,6 +68,7 @@ void RegisterCommentDialog::reloadComments(
const int row = static_cast<int>(index);
auto *address_item = new QTableWidgetItem(
QString::fromStdString(comment.address.toString()));
// UserRole 保存地址区域和下标,显示文本变化不会影响业务定位
address_item->setData(Qt::UserRole, static_cast<int>(comment.address.area()));
address_item->setData(Qt::UserRole + 1, comment.address.index());
ui_->commentTable->setItem(row, 0, address_item);
@@ -83,6 +89,7 @@ void RegisterCommentDialog::reloadComments(
}
}

// 将当前选中地址的注释加载到编辑表单
void RegisterCommentDialog::loadSelectedComment()
{
const std::optional<RegisterAddress> address = selectedAddress();
@@ -99,6 +106,7 @@ void RegisterCommentDialog::loadSelectedComment()
ui_->commentEdit->setText(fromUtf8(comment->text));
}

// 校验并保存当前表单中的寄存器注释
void RegisterCommentDialog::saveComment()
{
const RegisterAddress address = addressFromInputs();
@@ -112,6 +120,7 @@ void RegisterCommentDialog::saveComment()
reloadComments(&address);
}

// 确认后删除当前选中地址的寄存器注释
void RegisterCommentDialog::removeComment()
{
const std::optional<RegisterAddress> address = selectedAddress();
@@ -134,6 +143,7 @@ void RegisterCommentDialog::removeComment()
reloadComments();
}

// 将区域和下标输入组合为寄存器地址
RegisterAddress RegisterCommentDialog::addressFromInputs() const
{
const RegisterArea area = static_cast<RegisterArea>(
@@ -141,6 +151,7 @@ RegisterAddress RegisterCommentDialog::addressFromInputs() const
return RegisterAddress{area, ui_->indexSpinBox->value()};
}

// 从当前表格行保存的业务数据还原寄存器地址
std::optional<RegisterAddress> RegisterCommentDialog::selectedAddress() const
{
const int row = ui_->commentTable->currentRow();


+ 16
- 0
app/src/ui/runtime_monitor_widget.cpp 查看文件

@@ -19,11 +19,13 @@

namespace {

// 将 UTF-8 标准字符串转换为 Qt 文本
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 将 Qt 文本转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
@@ -32,6 +34,7 @@ std::string toUtf8(const QString &value)

} // namespace

// 创建并组合运行态 HMI、梯形图和自由监控视图
RuntimeMonitorWidget::RuntimeMonitorWidget(
HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service,
@@ -111,8 +114,10 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
});
}

// 释放 Designer 界面和 Qt 父子对象拥有的子控件
RuntimeMonitorWidget::~RuntimeMonitorWidget() = default;

// 绑定当前运行页面和控制逻辑,并刷新选择列表
void RuntimeMonitorWidget::setProjectObjects(
const std::string &page_id, const std::string &logic_id)
{
@@ -139,11 +144,13 @@ void RuntimeMonitorWidget::setProjectObjects(
: std::string{});
}

// 切换当前运行 HMI 页面
void RuntimeMonitorWidget::setRuntimePage(const std::string &page_id)
{
showRuntimePage(page_id);
}

// 根据编辑、离线或真机状态更新运行大屏的可见内容和操作资格
void RuntimeMonitorWidget::setMode(
ApplicationMode mode, PlcConnectionState plc_state)
{
@@ -191,6 +198,7 @@ void RuntimeMonitorWidget::setMode(
free_monitor_widget_->refreshValues(mode, plc_state);
}

// 切换只显示 HMI 的用户运行版布局
void RuntimeMonitorWidget::setHmiOnlyRuntime(bool enabled)
{
hmi_only_mode_ = enabled;
@@ -237,16 +245,19 @@ void RuntimeMonitorWidget::setHmiOnlyRuntime(bool enabled)
}
}

// 设置 HMI 按钮和数值输入是否允许写入
void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled)
{
hmi_view_->setRuntimeWriteEnabled(enabled);
}

// 设置自由监控是否允许写入寄存器
void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled)
{
free_monitor_widget_->setWriteEnabled(enabled);
}

// 从活动仓库刷新 HMI、报警和自由监控值
void RuntimeMonitorWidget::refreshValues(
ApplicationMode mode, PlcConnectionState plc_state)
{
@@ -258,6 +269,7 @@ void RuntimeMonitorWidget::refreshValues(
}
}

// 保存最新逻辑轨迹并投影到当前大屏显示的控制逻辑
void RuntimeMonitorWidget::setLogicTrace(
const LogicTraceSnapshot &trace, const std::string &fault_node_id)
{
@@ -267,16 +279,19 @@ void RuntimeMonitorWidget::setLogicTrace(
logic_view_->setRuntimeTrace(trace, fault_node_id);
}

// 返回运行监控中的自由监控子控件
FreeMonitorWidget *RuntimeMonitorWidget::freeMonitorWidget() const
{
return free_monitor_widget_;
}

// 返回当前大屏选中的控制逻辑标识
std::string RuntimeMonitorWidget::selectedLogicId() const
{
return selected_logic_id_;
}

// 校验页面后切换运行 HMI 投影
void RuntimeMonitorWidget::showRuntimePage(const std::string &page_id)
{
hmi_view_->setPageId(page_id);
@@ -290,6 +305,7 @@ void RuntimeMonitorWidget::showRuntimePage(const std::string &page_id)
? tr("未选择页面") : fromUtf8(page->name));
}

// 更新逻辑选择并投影对应的独立轨迹
void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id)
{
selected_logic_id_ = logic_id;


+ 10
- 0
app/src/ui/runtime_monitor_window.cpp 查看文件

@@ -6,6 +6,7 @@
#include <Qt>
#include <QWidget>

// 创建独立顶层运行窗口并隐藏编辑用界面元素
RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
: QMainWindow(
parent,
@@ -15,14 +16,17 @@ RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
ui_->setupUi(this);
}

// 释放 Designer 界面对象
RuntimeMonitorWindow::~RuntimeMonitorWindow() = default;

// 将唯一运行监控控件设置为窗口中央控件
void RuntimeMonitorWindow::setMonitorWidget(QWidget &widget)
{
widget.setParent(ui_->centralWidget);
ui_->runtimeMonitorWindowLayout->addWidget(&widget);
}

// 切换用户运行版的标准窗口关闭行为
void RuntimeMonitorWindow::setHmiOnlyRuntime(bool enabled)
{
hmi_only_runtime_ = enabled;
@@ -36,21 +40,26 @@ void RuntimeMonitorWindow::setHmiOnlyRuntime(bool enabled)
}
}

// 激活运行会话并将独立监控窗口最大化到前台
void RuntimeMonitorWindow::showForRuntime()
{
application_exit_ = false;
runtime_active_ = true;
// 运行态固定使用最大化窗口展示 HMI、梯形图轨迹和自由监控
showMaximized();
raise();
activateWindow();
}

// 返回编辑态时隐藏监控窗口但保留实例供下次复用
void RuntimeMonitorWindow::hideForEditing()
{
runtime_active_ = false;
// 主编程器继续保留在后面,隐藏大屏后即可回到编辑界面
hide();
}

// 标记应用退出并真正关闭顶层窗口
void RuntimeMonitorWindow::closeForApplicationExit()
{
application_exit_ = true;
@@ -58,6 +67,7 @@ void RuntimeMonitorWindow::closeForApplicationExit()
close();
}

// 运行中拦截系统关闭,用户运行版改为请求退出应用
void RuntimeMonitorWindow::closeEvent(QCloseEvent *event)
{
// 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭


+ 23
- 0
app/src/ui/runtime_panel_controller.cpp 查看文件

@@ -25,6 +25,7 @@

namespace {

// 将 UTF-8 标准字符串转换为 Qt 文本
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
@@ -32,6 +33,7 @@ QString fromUtf8(const std::string &value)

} // namespace

// 保存运行依赖和上层回调,窗口在 configure 时创建
RuntimePanelController::RuntimePanelController(
QWidget &parent,
RuntimeModeService &runtime_mode_service,
@@ -72,8 +74,10 @@ RuntimePanelController::RuntimePanelController(
{
}

// 释放控制器拥有的运行监控窗口
RuntimePanelController::~RuntimePanelController() = default;

// 创建独立运行窗口、运行投影控件和周期刷新回调
void RuntimePanelController::configure()
{
runtime_monitor_window_ = std::make_unique<RuntimeMonitorWindow>(&parent_);
@@ -136,10 +140,12 @@ void RuntimePanelController::configure()
QObject::connect(runtime_refresh_timer_, &QTimer::timeout,
&parent_, [this] { handleRuntimeTimer(); });
runtime_refresh_timer_->start();
// 排队处理扫描信号,保证场景更新发生在稳定的 UI 事件循环中
QObject::connect(&runtime_mode_service_.offlineSimulationService(),
&OfflineSimulationService::stateChanged,
&parent_, [this] { handleSimulationStateChanged(); },
Qt::QueuedConnection);
// 离线每轮扫描完成后把最新 LogicTraceSnapshot 投影到梯形图大屏
QObject::connect(&runtime_mode_service_.offlineSimulationService(),
&OfflineSimulationService::scanCompleted,
&parent_, [this] { handleScanCompleted(); },
@@ -147,12 +153,14 @@ void RuntimePanelController::configure()
QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(),
&OnlineLogicMonitorService::stateChanged,
&parent_, [this] { handleSimulationStateChanged(); });
// 真机每轮本地推算完成后复用同一入口刷新梯形图亮线
QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(),
&OnlineLogicMonitorService::scanCompleted,
&parent_, [this] { handleScanCompleted(); },
Qt::QueuedConnection);
}

// 将运行窗口和投影控件同步切换为只显示 HMI 的用户运行版
void RuntimePanelController::setHmiOnlyRuntime(bool enabled)
{
if (runtime_monitor_widget_ != nullptr)
@@ -165,11 +173,13 @@ void RuntimePanelController::setHmiOnlyRuntime(bool enabled)
}
}

// 返回当前唯一的运行监控投影控件
RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const
{
return runtime_monitor_widget_;
}

// 绑定运行起始页面并显示最大化运行窗口
void RuntimePanelController::enterRuntime(
const std::string &page_id,
const std::string &logic_id,
@@ -183,20 +193,26 @@ void RuntimePanelController::enterRuntime(
runtime_session_active_ = true;
}
runtime_monitor_widget_->freeMonitorWidget()->reloadAddresses();
// 根据离线或真机模式切换大屏的数据状态、写入权限和提示内容
runtime_monitor_widget_->setMode(mode, plc_state);
runtime_monitor_window_->setWindowTitle(
fromUtf8(project_service_.project().metadata.name));
// 工程投影准备完成后最大化显示唯一的运行监控窗口
runtime_monitor_window_->showForRuntime();
}

// 清除运行会话状态并隐藏运行监控窗口
void RuntimePanelController::leaveRuntime(
ApplicationMode mode, PlcConnectionState plc_state)
{
runtime_session_active_ = false;
// 先让大屏退出运行状态,防止隐藏后仍保留写入资格
runtime_monitor_widget_->setMode(mode, plc_state);
// 返回编辑态时只隐藏监控窗口,后续运行会复用同一个窗口
runtime_monitor_window_->hideForEditing();
}

// 停止刷新并允许顶层运行窗口随应用退出
void RuntimePanelController::closeForApplicationExit()
{
if (runtime_monitor_window_ != nullptr)
@@ -205,6 +221,7 @@ void RuntimePanelController::closeForApplicationExit()
}
}

// 根据离线或真机执行器状态更新标签、轨迹和故障提示
void RuntimePanelController::updateSimulationUi(bool report_fault)
{
const bool offline = runtime_mode_service_.mode()
@@ -295,6 +312,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)
error.nodeId);
if (runtime_monitor_widget_ != nullptr)
{
// 故障时也把最后一份轨迹和故障节点投影到运行大屏
runtime_monitor_widget_->setLogicTrace(
runtime_mode_service_.offlineSimulationService().traceSnapshot(),
error.nodeId);
@@ -330,6 +348,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)
}
}

// 定时刷新活动运行会话的寄存器投影
void RuntimePanelController::handleRuntimeTimer()
{
// 定时器只刷新投影;离线按定时器扫描,真机在完整 PLC 轮询后推算
@@ -348,11 +367,13 @@ void RuntimePanelController::handleRuntimeTimer()
updateSimulationUi(false);
}

// 执行器生命周期变化后刷新运行状态显示
void RuntimePanelController::handleSimulationStateChanged()
{
updateSimulationUi(true);
}

// 接收离线或真机扫描结果并刷新编辑投影和运行大屏亮线
void RuntimePanelController::handleScanCompleted()
{
const ApplicationMode mode = runtime_mode_service_.mode();
@@ -370,9 +391,11 @@ void RuntimePanelController::handleScanCompleted()
const LogicTraceSnapshot &trace = offline_running
? runtime_mode_service_.offlineSimulationService().traceSnapshot()
: runtime_mode_service_.onlineLogicMonitorService().traceSnapshot();
// 同一份完整轨迹先交给编辑投影,最终控件按当前逻辑 ID 筛选
logic_editor_widget_.setRuntimeTrace(trace);
if (runtime_monitor_widget_ != nullptr)
{
// 将最新轨迹交给运行监控大屏绘制触点、横线、竖线和输出状态
runtime_monitor_widget_->setLogicTrace(trace);
}
}

+ 14
- 0
app/src/ui/status_text_dialog.cpp 查看文件

@@ -15,17 +15,20 @@

namespace {

// 将 UTF-8 标准字符串转换为 Qt 文本
QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

// 将 Qt 文本转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

// 将可选区间边界格式化为数值或“不限”
QString boundaryText(const std::optional<double> &value)
{
return value.has_value()
@@ -34,6 +37,7 @@ QString boundaryText(const std::optional<double> &value)

} // namespace

// 加载状态文本现有映射并配置 M 位和 D 数值表单
StatusTextDialog::StatusTextDialog(const HmiControl &control, QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::StatusTextDialog>()),
@@ -66,6 +70,7 @@ StatusTextDialog::StatusTextDialog(const HmiControl &control, QWidget *parent)
ui_->dataTypeComboBox->findData(static_cast<int>(control.dataType)));
ui_->offTextEdit->setText(QStringLiteral("设备停止"));
ui_->onTextEdit->setText(QStringLiteral("设备运行"));
// 状态文本配置可能是 M 位文本或 D 值区间,按 variant 类型恢复页面
if (control.statusText.has_value()
&& std::holds_alternative<HmiStatusWordTextConfig>(*control.statusText))
{
@@ -94,13 +99,16 @@ StatusTextDialog::StatusTextDialog(const HmiControl &control, QWidget *parent)
updateSourcePage();
}

// 释放 Designer 界面对象
StatusTextDialog::~StatusTextDialog() = default;

// 返回用户确认后更新的状态文本控件
const HmiControl &StatusTextDialog::control() const
{
return control_;
}

// 根据寄存器来源切换 M 位或 D 数值配置页
void StatusTextDialog::updateSourcePage()
{
const bool word = ui_->sourceComboBox->currentData().toInt() == 1;
@@ -110,6 +118,7 @@ void StatusTextDialog::updateSourcePage()
ui_->addressLabel->setText(word ? tr("绑定地址(D)") : tr("绑定地址(M)"));
}

// 将一个 D 数值映射区间追加到表格
void StatusTextDialog::appendRangeRow(const HmiStatusValueRange &range)
{
const int row = ui_->rangeTableWidget->rowCount();
@@ -122,6 +131,7 @@ void StatusTextDialog::appendRangeRow(const HmiStatusValueRange &range)
row, 2, new QTableWidgetItem(fromUtf8(range.text)));
}

// 在区间数量上限内追加一个待编辑区间
void StatusTextDialog::addRange()
{
if (ui_->rangeTableWidget->rowCount()
@@ -134,6 +144,7 @@ void StatusTextDialog::addRange()
ui_->rangeTableWidget->rowCount() - 1, 0);
}

// 删除当前选中的 D 数值映射区间
void StatusTextDialog::removeSelectedRange()
{
const int row = ui_->rangeTableWidget->currentRow();
@@ -143,6 +154,7 @@ void StatusTextDialog::removeSelectedRange()
}
}

// 读取可为空的区间边界并拒绝非有限数值
bool StatusTextDialog::readOptionalBoundary(
int row, int column, std::optional<double> *value)
{
@@ -154,6 +166,7 @@ bool StatusTextDialog::readOptionalBoundary(
return true;
}
bool converted = false;
// 固定使用 C locale,工程格式中的小数点不受系统区域设置影响
const double number = QLocale::c().toDouble(text, &converted);
if (!converted || !std::isfinite(number))
{
@@ -167,6 +180,7 @@ bool StatusTextDialog::readOptionalBoundary(
return true;
}

// 生成完整映射配置,通过领域校验后接受对话框
void StatusTextDialog::accept()
{
const bool word = ui_->sourceComboBox->currentData().toInt() == 1;


+ 12
- 2
app/src/ui/status_text_dialog.h 查看文件

@@ -10,27 +10,37 @@ namespace Ui {
class StatusTextDialog;
}

// 编辑状态文本的 M 位映射或 D 数值连续区间
class StatusTextDialog final : public QDialog
{
Q_OBJECT

public:
// 创建对话框并加载状态文本控件的现有映射
explicit StatusTextDialog(const HmiControl &control, QWidget *parent = nullptr);
// 释放 Designer 界面对象
~StatusTextDialog() override;

// 返回用户确认后更新的状态文本控件
const HmiControl &control() const;

private slots:
// 根据 M 位或 D 数值来源切换配置页面
void updateSourcePage();
// 在 D 数值映射表末尾添加一个空区间
void addRange();
// 删除 D 数值映射表中当前选中的区间
void removeSelectedRange();
// 读取表单、校验完整映射并保存控件副本
void accept() override;

private:
// 将一个 D 数值映射区间追加到表格
void appendRangeRow(const HmiStatusValueRange &range);
// 读取可为空的区间边界,非法文本时显示错误
bool readOptionalBoundary(
int row, int column, std::optional<double> *value);

std::unique_ptr<Ui::StatusTextDialog> ui_;
HmiControl control_;
std::unique_ptr<Ui::StatusTextDialog> ui_; // Qt Designer 生成的界面对象
HmiControl control_; // 正在编辑的状态文本控件副本
};

+ 10
- 0
app/src/ui/toolbar_icon_factory.cpp 查看文件

@@ -16,6 +16,7 @@ const QColor kWarning(QStringLiteral("#b87808"));
const QColor kDanger(QStringLiteral("#a84545"));
const QColor kPaper(QStringLiteral("#ffffff"));

// 创建带圆角端点和圆角连接的统一图标画笔
QPen iconPen(const QColor &color = kInk, qreal width = 1.7)
{
QPen pen(color, width);
@@ -24,6 +25,7 @@ QPen iconPen(const QColor &color = kInk, qreal width = 1.7)
return pen;
}

// 在指定矩形中绘制统一字体和颜色的居中文本
void drawCenteredText(
QPainter &painter,
const QRectF &bounds,
@@ -40,6 +42,7 @@ void drawCenteredText(
painter.drawText(bounds, Qt::AlignCenter, text);
}

// 绘制 PLC 设备外形和状态指示细节
void drawPlc(QPainter &painter)
{
painter.setPen(iconPen());
@@ -56,6 +59,7 @@ void drawPlc(QPainter &painter)
painter.drawLine(QPointF(11, 16), QPointF(16, 16));
}

// 绘制软盘语义的保存图标
void drawSave(QPainter &painter)
{
painter.setPen(iconPen());
@@ -65,6 +69,7 @@ void drawSave(QPainter &painter)
painter.drawRect(QRectF(7, 13, 10, 8));
}

// 绘制常开、常闭或带助记符的触点图标
void drawContact(QPainter &painter, const QString &label, bool closed)
{
painter.setPen(iconPen());
@@ -82,6 +87,7 @@ void drawContact(QPainter &painter, const QString &label, bool closed)
}
}

// 绘制带可选助记符的线圈图标
void drawCoil(QPainter &painter, const QString &label)
{
painter.setPen(iconPen());
@@ -101,6 +107,7 @@ void drawCoil(QPainter &painter, const QString &label)
}
}

// 绘制 MOVE、ADD、SUB 或比较指令块
void drawInstructionBlock(QPainter &painter, const QString &label)
{
painter.setPen(iconPen());
@@ -109,12 +116,14 @@ void drawInstructionBlock(QPainter &painter, const QString &label)
drawCenteredText(painter, QRectF(3, 5.5, 18, 13), label, 5.5);
}

// 按语义类型在透明位图上绘制一个图标
QPixmap renderIcon(UiIcon icon, int size)
{
QPixmap pixmap(size, size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
// 所有图形使用 24×24 逻辑坐标,统一缩放到请求的实际像素尺寸
painter.scale(static_cast<qreal>(size) / 24.0, static_cast<qreal>(size) / 24.0);
painter.setPen(iconPen());
painter.setBrush(Qt::NoBrush);
@@ -549,6 +558,7 @@ QPixmap renderIcon(UiIcon icon, int size)

} // namespace

// 生成包含常用分辨率的 Qt 图标对象
QIcon makeUiIcon(UiIcon icon)
{
QIcon result;


+ 12
- 3
app/tests/domain_tests.cpp 查看文件

@@ -689,7 +689,11 @@ void testQuantityBoundaries()
}
require(project.validate(defaultProjectLimitSettings()), "an HMI page count at the configured limit must be accepted");
project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}});
require(!project.validate(defaultProjectLimitSettings()), "an HMI page count of 129 must be rejected");
require(
!project.validate(defaultProjectLimitSettings()),
"an HMI page count of "
+ std::to_string(ProjectLimits::kMaximumHmiPages + 1U)
+ " must be rejected");

project = makeValidProject();
project.hmiPages.clear();
@@ -739,7 +743,11 @@ void testQuantityBoundaries()
extra_label.bounds = {0, 0, 1, 1};
extra_label.text = "label";
project.hmiPages.front().controls.push_back(std::move(extra_label));
require(!project.validate(defaultProjectLimitSettings()), "a page control count of 513 must be rejected");
require(
!project.validate(defaultProjectLimitSettings()),
"a page control count of "
+ std::to_string(ProjectLimits::kMaximumHmiControlsPerPage + 1U)
+ " must be rejected");

project = makeValidProject();
project.controlLogics.clear();
@@ -749,7 +757,8 @@ void testQuantityBoundaries()
"bulk-logic-" + std::to_string(logic_index),
"Bulk logic " + std::to_string(logic_index),
{},
true};
true,
{}};
for (std::size_t rung_index = 0U;
rung_index < ProjectLimits::kMaximumRungsPerLogic;
++rung_index)


+ 2
- 2
app/tests/logic_editor_service_tests.cpp 查看文件

@@ -618,7 +618,7 @@ void testCommandInputMapsToGridCoordinates()
"condition commands must not be accepted in the output slot");
}

void testProjectRungLimitAppliesToBranchAndPaste()
void testProjectRungLimitAppliesToParallelBranch()
{
Fixture fixture;
const std::string source = fixture.addRung();
@@ -1500,7 +1500,7 @@ int main()
{"testSelectionDeletionIsAtomic", testSelectionDeletionIsAtomic},
{"testInvalidSelectionDeletionDoesNotMutateOrRecordHistory", testInvalidSelectionDeletionDoesNotMutateOrRecordHistory},
{"testCommandInputMapsToGridCoordinates", testCommandInputMapsToGridCoordinates},
{"testProjectRungLimitAppliesToBranchAndPaste", testProjectRungLimitAppliesToBranchAndPaste},
{"testProjectRungLimitAppliesToParallelBranch", testProjectRungLimitAppliesToParallelBranch},
{"testConfiguredRowLimit", testConfiguredRowLimit},
{"testFirstEditOnEmptyLogicIsAtomic", testFirstEditOnEmptyLogicIsAtomic},
{"testCursorAdvanceAndOutputTransaction", testCursorAdvanceAndOutputTransaction},


+ 2
- 0
app/tests/pri/test_layers.pri 查看文件

@@ -111,10 +111,12 @@ SERVICE_OFFLINE_HEADERS = \

SERVICE_RUNTIME_SOURCES = \
../src/services/runtime_mode_service.cpp \
../src/services/user_runtime_plc_recovery_policy.cpp \
../src/services/plc_discovery_gateway.cpp

SERVICE_RUNTIME_HEADERS = \
../src/services/runtime_mode_service.h \
../src/services/user_runtime_plc_recovery_policy.h \
../src/services/plc_discovery_gateway.h

SERVICE_MONITOR_SOURCES = \


+ 33
- 0
app/tests/runtime_mode_service_tests.cpp 查看文件

@@ -1,4 +1,5 @@
#include "services/runtime_mode_service.h"
#include "services/user_runtime_plc_recovery_policy.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
@@ -132,6 +133,37 @@ private:

using TestSupport::require;

void testUserRuntimePlcRecoveryPolicy()
{
UserRuntimePlcRecoveryPolicy policy;
require(
policy.shouldReconnectSerial(PlcConnectionState::Disconnected),
"a disconnected local serial port must use a full reconnect");
require(
!policy.shouldReconnectSerial(PlcConnectionState::Faulted),
"a PLC communication fault must keep the serial session for recovery probing");

require(
policy.beginFailureEpisode() && policy.failureEpisodeActive(),
"the first error in one communication outage must be reported");
require(
!policy.beginFailureEpisode(),
"later error changes in the same outage must not be reported again");

policy.observeStatus(PlcConnectionState::Connected, false);
require(
policy.failureEpisodeActive(),
"opening the serial port without a complete PLC read must not end the outage");
policy.observeStatus(PlcConnectionState::Recovering, true);
require(
policy.failureEpisodeActive(),
"a recovering connection must remain in the same outage until fully connected");
policy.observeStatus(PlcConnectionState::Connected, true);
require(
!policy.failureEpisodeActive() && policy.beginFailureEpisode(),
"a connected PLC with a complete first read must allow a later outage to report once");
}

void setConditionPath(LadderRung *rung, LogicNode node)
{
for (int column = 0;
@@ -556,6 +588,7 @@ void testDisconnectedOutputBlocksOfflineAndOnlineRuntime()
int main()
{
return TestSupport::runTestSuite("runtime mode service tests", {
{"testUserRuntimePlcRecoveryPolicy", testUserRuntimePlcRecoveryPolicy},
{"testModeTransitions", testModeTransitions},
{"testHmiOnlyRuntimeSkipsLogicExecutor", testHmiOnlyRuntimeSkipsLogicExecutor},
{"testMonitorPollRangeRollback", testMonitorPollRangeRollback},


+ 1
- 1
app/tests/runtime_project_bundle_tests.cpp 查看文件

@@ -28,7 +28,7 @@ void testBundleRoundTripAndValidation()
const QString bundle_path = directory.filePath(QStringLiteral("motor.exe"));
const QByteArray template_bytes("MZ-template-binary");
const QByteArray project_bytes(
"{\"formatVersion\":\"1.0\",\"name\":\"motor\"}");
"{\"formatVersion\":\"4.0\",\"name\":\"motor\"}");
writeFile(template_path, template_bytes);
writeFile(project_path, project_bytes);



+ 14
- 9
docs/ai/handoff.md 查看文件

@@ -1,10 +1,10 @@
# 当前开发交接

> 更新日期:2026-08-29。本文件只保留当前工作区、本轮改动、验证结果和后续人工检查项。
> 更新日期:2026-08-30。本文件只保留当前工作区、本轮改动、验证结果和后续人工检查项。

## 当前状态

- 测试程序已优化:功能测试入口按用例独立报告 `[PASS]/[FAIL]`,单个用例失败不会跳过同一目标的其他用例;HMI 数值运行测试拆出独立用例并去除重复原始字序断言
- 测试程序已优化:功能测试入口按用例独立报告 `[PASS]/[FAIL]`,单个用例失败不会跳过同一目标的其他用例;HMI 数值运行测试拆出独立用例并去除重复原始字序断言;数量边界失败提示由统一上限常量生成,运行包夹具同步为工程格式 `4.0`
- 性能测试已调整为合法上限扫描、超限压力扫描、4001 个 M 位边界访问和 256 个 D 字连续块访问四项基准
- HMI 编辑态支持基础控件布局对齐:左、水平居中、右、顶部、垂直居中、底部;至少选中两个控件后从 HMI 工具栏“布局”菜单执行,对齐只修改控件位置并支持单步撤销/重做
- `docs/用户使用/状态文本控件说明.md` 的状态文本控件已完成,新增独立 `HmiControlType::StatusText`,普通 `Label` 保持固定文本职责
@@ -16,11 +16,13 @@
- HMI 控件按类型自动固定绑定区域:按钮/指示灯为 M,数值显示/输入为 D;新增控件自动分配当前区域的顺序地址,属性面板只修改地址编号,状态文本仍在专用对话框选择 M 或 D
- HMI 按钮保留置 ON、置 OFF、翻转和点动四种操作,并可附加一个 M 位或 D 数值启用条件;条件不满足或读取失败时按钮置灰且禁止操作
- 按钮启用条件配置使用独立 Qt Designer 对话框;支持 M 地址 + OFF/ON、D 地址 + 四种数据类型 + 六种比较方式 + 比较值
- 编辑态数据监控与运行态自由监控使用同一套读写行为:未连接 PLC 时读写虚拟 M/D,连接并完成首读后自动读写 PLC 缓存;PLC 首读期间禁止写入,编辑态 PLC 写入不更新离线初始值
- 编辑态数据监控新增“离线 M/D/真机 PLC M/D”显式选择,默认离线且连接 PLC 不自动切换;真机必须连接并完成首读,断线后保留真机选择、显示不可用并禁写,重连首读后恢复;两套数据互不复制
- 数据监控和自由监控输入 M 地址时,类型框固定显示 `Bit(位)` 并禁止选择数值类型;输入 D 地址时恢复四种数值类型
- 普通编程器的运行监控窗口也提供离线/真机模式下拉切换;跨运行模式时自动经过编辑态,真机首读和工程校验失败会保持原运行态
- 用户运行程序已经收敛为 HMI 专用运行版:导出时只保留 HMI 页面、报警和导航数据,移除控制逻辑与地址备注;运行版隐藏编辑器、梯形图、自由监控、离线模式和串口配置控件
- 用户运行版启动后自动读取同目录 `config/runtime.ini` 的六个串口字段并连接真实 PLC;运行版不启动本地梯形图扫描,HMI 读写只在 PLC 首读完成后放行
- 用户运行版串口被拔出或 PLC 端口超时后自动按配置重连,重连和重新首读期间保持 HMI 页面、禁止写入且绝不切换到虚拟数据;普通编程器仍使用原来的手动连接和故障回编辑态流程
- 用户运行版恢复路径已收敛:USB 串口消失时每 3 秒完整重连,PLC 无响应或超时时保留串口并由通信层每 2 秒轻量探测;同一次故障只提示一次,串口打开但首读未完成不会清除提示周期
- `json/` 下的两个本地示例工程已同步到严格 `formatVersion: "4.0"`,按钮补齐 `buttonEnableCondition: null` 字段,页面、报警、导航和梯形图数据保持原样
- 关键 Markdown 文档已按当前代码和文档职责复核,修正 JSON `4.0` 版本约定、状态文本文档引用和验收测试目标数量

@@ -51,29 +53,32 @@

## 验证结果

- 14 个 Release Functional 测试目标全部通过,功能用例逐项输出结果:领域 18、设置 6、运行版设置 4、报警 2、HMI 编辑 12、逻辑编辑 27、离线仿真 19、工程管理 11、监控 7、运行包 1、运行模式 4、运行面板 13、PLC 对话框 3、PLC 运行时 7,共 134 个用例
- 14 个 Release Functional 测试目标全部通过,功能用例逐项输出结果:领域 18、设置 6、运行版设置 4、报警 2、HMI 编辑 12、逻辑编辑 27、离线仿真 19、工程管理 11、监控 7、运行包 1、运行模式 5、运行面板 13、PLC 对话框 3、PLC 运行时 7,共 135 个用例
- M 地址类型框联动修改后,14 个 Release Functional 测试目标重新运行并全部通过,Release 主程序构建成功
- Release Performance 四项基准全部通过:合法上限规模扫描约 21.75 ms/次,超限压力扫描约 1.09 ms/次,4001 个 M 位逐地址读写约 0.0479 ms/次,256 个 D 字连续块读写约 0.000062 ms/次;结果仅作为本机执行器和虚拟仓库基线
- HMI 编辑专项覆盖六种对齐方向、批量对齐单步撤销/重做、非法选择原子性和无变化不产生历史记录
- 状态文本专项覆盖 M OFF/ON 映射、D 四类型区间边界、区间间隙/重叠、整数边界、NaN/Inf、只读约束、16 区间上限、JSON 往返和旧 `2.0` 拒绝
- 按钮专项覆盖 M/D 条件满足与不满足、服务层二次拒绝、点动条件变化后的释放复位、条件 JSON 往返和条件引用地址轮询
- 属性面板专项覆盖按钮/数值控件自动分配连续默认地址,以及手动修改地址编号后保持固定区域
- 运行模式专项覆盖 Double 状态文本的 `D70~D73` 轮询地址及四字 `RegisterWordRange`
- 数据监控专项覆盖连接 PLC 不自动切换、首读前拒绝真机选择、离线/真机读写隔离、断线保持真机选择并禁写、重连首读后恢复,以及运行态继续跟随活动仓库
- 运行版恢复策略专项覆盖仅 `Disconnected` 完整重连、`Faulted` 保留串口探测、一次故障只提示一次,以及必须 `Connected + 首读完成` 才结束故障周期
- `git diff --check` 已通过;当前尚未连接真实 PLC,本轮新增编辑态 PLC 监控读写使用 Fake/缓存仓库验证,真实设备读写仍需按 STOP 安全流程现场确认
- Release 主程序已在本轮最终代码上重新构建;新增运行版启动链路、最小配置加载和自动重连逻辑通过编译,设置 Qt offscreen 环境后启动验证仍保持事件循环;导出运行版的真实串口连接尚未接入设备验证
- Release 主程序已在本轮最终代码上重新构建;新增运行版启动链路、最小配置加载和收敛后的自动恢复逻辑通过编译,设置 Qt offscreen 环境后启动验证仍保持事件循环;导出运行版的真实串口连接、拔线和恢复尚未接入设备验证

## 工作区说明

- 保留用户已有的 `AGENTS.md` 修改和未跟踪 Word 临时文件,不覆盖、不回退
- 本轮代码按用户确认提交;Markdown 文档和来源不明的非代码文件继续保留在工作区,不纳入本次提交
- 当前有三份此前未提交的测试修正:数量边界提示和测试夹具初始化、工程总行数分支测试名称、运行包工程格式夹具;本轮新增数据监控、用户运行版 PLC 恢复策略、界面调用、专项测试和对应文档改动,来源不明的非代码文件继续保留且未修改

## 后续人工检查

- 编辑态连接 PLC 并完成首读,打开“数据监控”,确认数据源切换为 PLC 缓存、M/D 值持续刷新且写入后等待轮询确认
- 编辑态 PLC 正在首读或断开时,确认监控显示对应等待/虚拟状态,写入权限符合提示
- 编辑态保持“离线 M/D”后连接 PLC,确认监控不会自动切换;完成首读后手动切到“真机 PLC M/D”,确认 M/D 持续刷新且写入后等待轮询确认
- 分别在 PLC 未连接、正在连接和正在首读时尝试选择真机,确认切换被拒绝并说明原因;真机选择期间断线时确认仍显示真机、值不可用且禁写,手动切回离线后恢复离线值
- 打开 HMI 页面添加“状态文本”,确认属性面板的专用配置按钮、M/D 切换和区间表布局在常用缩放下无重叠
- 打开 HMI 页面添加按钮,分别配置 M 位和 D 数值启用条件,确认条件不满足时按钮置灰且无法点击,满足后恢复可操作
- 离线运行写入虚拟 M/D,确认 M OFF/ON 和 D 在 30、80 边界的显示结果;停止运行后恢复编辑占位词
- 设备可用时在 STOP 状态读取原值,确认状态文本使用的 M/D 被轮询读回;测试只读,不向设备写入状态文本数据
- 用真实工程执行一次“导出用户运行程序”,确认导出目录只出现 HMI 运行 exe、Qt 依赖、平台插件、样式目录和 `config/runtime.ini`,并确认 exe 启动后不出现编辑器或串口配置界面
- 设备可用时使用导出运行版按 `runtime.ini` 自动连接,在 STOP 状态确认首读和 HMI 读回;测试结束恢复全部临时写入值并再次读回
- 真机运行中分别拔出 USB 串口适配器、断开 PLC 侧 RS-485 端口并重新接回,确认运行版自动重连、重新首读、恢复 HMI 读写权限,且重连期间不显示虚拟数据
- 真机运行中分别拔出 USB 串口适配器、断开 PLC 侧 RS-485 端口并重新接回;确认 USB 断线走完整串口重连、PLC 侧断线走轻量探测,同一次故障只提示一次,恢复后重新首读并恢复 HMI 写入,且恢复期间不显示虚拟数据

+ 2
- 0
docs/architecture.md 查看文件

@@ -201,6 +201,8 @@ PLC 缓存只保存最后一次成功读回值。写请求受理后不乐观修

通信状态包括 `Disconnected / Connecting / Connected / Recovering / Faulted`。连接失败或物理断线会关闭串口会话并进入 `Disconnected`;超时、协议或读写故障会停止正常轮询、保留最后有效缓存并进入 `Faulted`。所有通信故障都会撤销首读资格;若当时处于真机运行,运行模式服务立即返回编辑态。可恢复超时探测成功后进入 `Recovering` 并重新完整首读,但不会自动重新进入真机运行。

用户运行版按故障层级使用唯一恢复路径:本地 USB 串口消失进入 `Disconnected` 后,每 3 秒尝试一次完整串口重连;本地串口仍打开但 PLC 无响应或通信超时进入 `Faulted` 后,只由通信服务保留串口并每 2 秒发送轻量读取探测,界面层不得再次关闭和重开串口。一次故障周期只提示一次,错误分类或文字变化不产生新弹窗;只有状态恢复为 `Connected` 且重新完成全部轮询块首读后,才结束本轮故障并允许下一次故障提示。运行版恢复期间保持 HMI 页面和最后有效显示,但禁止写入。

## 扩展规则

- 新业务先确定所属领域模型和服务用例,再添加 UI;不得从 UI 直接改工程容器或串口状态


+ 897
- 0
docs/代码功能阅读清单.md 查看文件

@@ -0,0 +1,897 @@
# 综合平台编程器代码功能阅读清单

> 适用代码状态:2026-08-29
>
> 这份文档用于按功能逐章阅读当前代码。它不是开发计划,也不是需求清单。这里列出的功能都能在当前源码中找到实现;每章最后的复选框用于记录阅读进度。

## 1. 先看结论

当前代码可以按 **17 个功能模块** 阅读。前 4 章建立公共模型和工程基础,第 5~12 章是编辑功能,第 13~16 章是运行与 PLC 链路,第 17 章是用户运行程序交付链路。

| 顺序 | 功能模块 | 主要解决的问题 |
| ---: | --- | --- |
| 1 | 程序启动与应用配置 | 程序如何创建对象、加载配置并决定启动为编程器还是用户运行版 |
| 2 | M/D 寄存器公共模型 | 地址、四种 D 数据类型、编解码和统一仓库接口如何工作 |
| 3 | 工程聚合、数量边界与校验 | 一个工程保存什么,以及“可保存”和“可运行”的区别 |
| 4 | 工程新建、保存、加载与 JSON | 工程操作如何原子完成,严格 JSON `4.0` 如何读写 |
| 5 | 主窗口、工程树与编辑会话 | UI 如何组织页面、逻辑、属性面板和动作状态 |
| 6 | HMI 页面管理 | 页面新增、改名、排序、尺寸、初始页和安全删除 |
| 7 | HMI 控件编辑 | 8 类控件的添加、属性、绑定、移动、对齐、删除和历史 |
| 8 | HMI 运行交互与页面导航 | 控件如何读取/写入活动仓库,按钮、状态文本和页面跳转如何运行 |
| 9 | 报警、报警列表与地址注释 | 报警定义/记录以及 M/D 地址备注如何编辑和显示 |
| 10 | 连续梯形图模型与逻辑组 | 10 个条件格、输出槽、竖线、网络和多逻辑组的数据结构 |
| 11 | 梯形图画布与编辑操作 | 行、横竖线、框选、删除、复制粘贴、撤销重做和网络注释 |
| 12 | 梯形图指令、命令语与检查 | 触点、线圈、比较、MOVE、ADD/SUB、连续输入和语法检查 |
| 13 | 软件逻辑执行与离线仿真 | 扫描语义、边沿状态、运行轨迹、定时扫描和离线初始值 |
| 14 | 数据监控 | 编辑态/运行态监控、批量地址、四种 D 类型和数据源显式切换 |
| 15 | PLC 配置、搜索与 Modbus RTU | 串口参数、自动搜索、异步轮询、缓存、写入、故障和恢复 |
| 16 | 运行模式与运行监控窗口 | 编辑/离线/真机切换、活动仓库、真机本地推算轨迹和运行大屏 |
| 17 | 用户运行程序导出与启动 | HMI 工程裁剪、exe 尾部封装、依赖复制、自动连接和自动重连 |

推荐总顺序就是表格中的 `1 -> 17`。不要一上来通读 `main_window.cpp`:它是窗口级协调层,里面同时出现多数功能入口,缺少前面的模型和服务背景时很容易看乱。

## 2. 全局阅读规则

### 2.1 每个功能固定按这个方向看

```text
领域模型/规则 -> 服务用例 -> 基础设施实现(若有) -> UI 入口/投影 -> 对应测试
```

- `domain`:数据长什么样、什么情况算合法,不依赖 Qt、串口或文件系统
- `services`:一次完整业务操作怎么执行、失败如何回滚、需要哪些外部契约
- `infrastructure`:JSON、INI、exe 封装和 Qt Modbus 等技术实现
- `ui`:收集用户输入、调用服务、刷新画面,不应重新实现业务规则
- `tests`:当前代码真正承诺了哪些边界和失败行为

### 2.2 每章怎样才算看完

每章至少做到下面四件事,再勾选章节完成项:

1. 能说出这个功能保存或操作的核心数据结构
2. 能从一个 UI 动作追到服务,再追到领域对象或基础设施
3. 能说出一个成功路径和两个主要失败路径
4. 能在对应测试中找到证明这些行为的用例

### 2.3 开始前先读的三个入口

- [ ] 阅读 `docs/ai/handoff.md`,了解工作区当前状态和最近验证
- [ ] 阅读 `docs/architecture.md`,建立分层、数据流和运行模式概念
- [ ] 浏览 `app/src/main.cpp`,只看对象有哪些,先不要深入每个对象

---

## 3. 功能 1:程序启动与应用配置

### 功能范围

- 普通编程器读取可执行文件旁的 `config/application.ini`
- HMI 专用运行版读取自己的 `config/runtime.ini`
- 配置不存在时创建默认文件;严格校验失败时整份回退默认值
- 启动时识别当前 exe 是否封装了工程,决定进入普通编程器或用户运行版
- 在 `main.cpp` 中创建所有领域仓库、服务、基础设施和主窗口,并完成依赖注入

### 建议阅读顺序

1. `app/src/services/application_settings.h`
2. `app/src/infrastructure/application_settings_loader.h/.cpp`
3. `app/src/infrastructure/runtime_settings_loader.h/.cpp`
4. `app/src/main.cpp`
5. `docs/用户使用/应用配置说明.md`

### 重点调用链

```text
main()
-> RuntimeProjectBundleService::load()
-> ApplicationSettingsLoader::load() 或 RuntimeSettingsLoader::load()
-> 构造 ProjectService / 编辑服务 / 寄存器仓库 / 运行服务
-> RuntimeModeService::configurePlc()
-> MainWindow
-> QApplication::exec()
```

### 阅读检查

- [ ] 能解释为什么用户运行版不加载完整的 `application.ini`
- [ ] 能找到 `ProjectLimitSettings`、`HmiDefaultSettings` 和 PLC 默认参数的注入位置
- [ ] 能解释 `ActiveRegisterRepository`、虚拟仓库和 PLC 缓存为何都是独立对象
- [ ] 能解释配置警告为什么不阻止主窗口创建
- [ ] 查看 `application_settings_tests` 和 `runtime_settings_tests`

---

## 4. 功能 2:M/D 寄存器公共模型

### 功能范围

- 支持 `M0~M4000` 位地址和 `D0~D4000` 字地址
- Modbus 地址使用从 `0` 开始的原始地址
- D 数值支持 `Int16`、`Int32`、`Float32`、`Float64`
- 多字值按低地址低字顺序编码,分别占 1、2、2、4 个 D 字
- `Float64` 起始地址要求偶数;所有多字范围不能越过 D4000
- 虚拟仓库保存离线值,PLC 仓库保存最新读回缓存,活动仓库代理当前运行数据源

### 建议阅读顺序

1. `app/src/domain/register_address.h/.cpp`
2. `app/src/domain/register_value_type.h/.cpp`
3. `app/src/domain/register_repository.h`
4. `app/src/domain/virtual_register_repository.h/.cpp`
5. `app/src/infrastructure/plc_register_repository.h/.cpp`
6. `app/src/domain/active_register_repository.h/.cpp`
7. `docs/二次开发/信捷D寄存器与浮点扩展说明.md`

### 重点调用链

```text
HMI / 监控 / 软件执行器
-> RegisterRepository
-> VirtualRegisterRepository(离线)
-> PlcRegisterRepository(真机读回缓存 + 异步写回调)
-> ActiveRegisterRepository(按模式转发)
```

### 阅读检查

- [ ] 能解释 `RegisterAddress` 如何区分 M/D 并校验 `0~4000`
- [ ] 能手算 `D10` 上一个 Int32 或 Float32 会占用哪些字
- [ ] 能解释 `Int32Codec`、`Float32Codec`、`Float64Codec` 的低字在前规则
- [ ] 能解释 NaN/Inf 为什么被拒绝
- [ ] 能解释 PLC 写入成功提交后为什么不立即修改缓存
- [ ] 在 `domain_tests` 中查看地址边界和四种数据类型编解码测试

---

## 5. 功能 3:工程聚合、数量边界与校验

### 功能范围

- `Project` 聚合元数据、HMI 页面、初始页、报警、地址注释和控制逻辑
- 稳定 ID 用于引用,容器顺序决定页面和逻辑的显示/扫描顺序
- 所有硬上限集中在 `project_limits.h`,INI 只能收紧部分上限
- `validate()` 允许保存未配置完成的编辑草稿
- `validateForRunning()` 进一步拒绝未绑定控件、未配置节点和不可运行结构
- 工程级校验还负责跨对象规则:页面跳转目标、D 多字占用冲突、16 位指令写入保护、PLC 去重轮询地址上限

### 建议阅读顺序

1. `app/src/domain/project_limits.h`
2. `app/src/domain/project_model.h/.cpp`
3. `app/src/domain/hmi_model.h/.cpp`
4. `app/src/domain/alarm_model.h/.cpp`
5. `app/src/domain/control_logic_model.h/.cpp`
6. `docs/用户使用/数量边界确认方案.md`

### 当前主要硬边界

| 项目 | 上限 |
| --- | ---: |
| HMI 页面 | 32 |
| 每页 HMI 控件 | 128 |
| 全工程 HMI 控件 | 2048 |
| 报警定义 | 256 |
| 控制逻辑组 | 32 |
| 每组梯形图行 | 256 |
| 全工程梯形图行 | 2048 |
| 每行条件格 | 固定 10 |
| 状态文本 D 区间 | 16 |
| PLC 去重轮询地址 | 256 |
| PLC 轮询块 | 8 |
| 单个工程文件 | 16 MiB |

### 阅读检查

- [ ] 画出 `Project -> HmiPage -> HmiControl` 和 `Project -> ControlLogic -> LadderRung` 两棵对象树
- [ ] 能说清 `validate()` 与 `validateForRunning()` 的使用时机
- [ ] 找到 HMI 多字 D 范围重叠检查
- [ ] 找到 MOVE/ADD/SUB 不能写入多字 HMI 中间字的保护
- [ ] 找到工程引用地址去重后最多 256 个的校验
- [ ] 查看 `domain_tests` 中的模型、数量边界和运行可达性测试

---

## 6. 功能 4:工程新建、保存、加载与 JSON

### 功能范围

- 新建工程、保存、另存为、加载和导出 JSON
- 当前只接受严格 `formatVersion: "4.0"`
- 旧 `1.0/2.0/3.0` 和未知版本直接拒绝,不迁移、不兼容
- 加载先解析到临时对象,全部成功后才替换当前工程
- 保存先做领域校验,再使用临时文件和原子提交
- 保存成功后更新当前文件路径和“未保存修改”状态;失败保持原状态

### 建议阅读顺序

1. `app/src/domain/project_storage.h`
2. `app/src/services/project_service.h/.cpp`
3. `app/src/infrastructure/json_project_storage.h/.cpp`
4. `docs/工程格式说明.md`
5. `app/tests/project_management_tests.cpp`

### 重点调用链

```text
MainWindow::saveProject()/loadProject()
-> ProjectService::save()/load()
-> Project::validate()
-> JsonProjectStorage::save()/load()
-> 文件系统
```

### 阅读检查

- [ ] 能追踪一次“另存为”从 QAction 到 JSON 文件的完整链路
- [ ] 能解释加载非法文件时为什么不会污染当前工程
- [ ] 能解释 `editProject()`、`isModified()` 和 `restoreModifiedState()` 的关系
- [ ] 能在 JSON 代码中找到 8 类 HMI 控件、报警、注释和连续梯形图字段
- [ ] 能找到版本检查和原子写入实现
- [ ] 查看 `project_management_tests` 的 JSON 往返、非法文件和修改状态测试

---

## 7. 功能 5:主窗口、工程树与编辑会话

### 功能范围

- Qt Designer 管理主窗口静态布局
- 工程树管理 HMI 页面和控制逻辑的当前选择
- 编辑区包含“HMI 页面”“控制逻辑”“数据监控”三个页签
- 属性面板根据当前页面、控件、逻辑或节点切换表单
- `ModePolicy` 统一控制编辑态和运行态动作是否可用
- 状态栏和输出面板显示操作、校验、仿真和通信消息

### 建议阅读顺序

1. 用 Qt Designer 打开 `app/src/ui/main_window.ui`
2. `app/src/ui/main_window.h`
3. `app/src/ui/project_workspace_controller.h/.cpp`
4. `app/src/ui/property_panel_controller.h/.cpp`
5. `app/src/ui/main_window.cpp` 中的构造、`configure*` 和 `update*Ui` 部分
6. `app/src/ui/toolbar_icon_factory.h/.cpp`

### UI 职责分工

| 类 | 职责 |
| --- | --- |
| `MainWindow` | 窗口级动作、文件对话框、控制器组合、模式请求和运行版导出 |
| `ProjectWorkspaceController` | 工程树、当前页面/逻辑、增删改排序和刷新编辑器 |
| `PropertyPanelController` | 展示并提交页面、控件和节点属性 |
| `RuntimePanelController` | 运行窗口生命周期和运行数据投影 |

### 阅读检查

- [ ] 能找到三个编辑页签和左右/底部面板的创建位置
- [ ] 能解释工程树选择如何切换 HMI 或梯形图编辑器当前对象
- [ ] 能解释为什么当前页面/当前逻辑不写入工程 JSON
- [ ] 能找到 QAction 如何连接到控制器或服务
- [ ] 能找到 `ModePolicy` 如何禁用运行期间的编辑入口
- [ ] 暂时不要逐行阅读整个 `main_window.cpp`,后续章节再回来看对应函数

---

## 8. 功能 6:HMI 页面管理

### 功能范围

- 没有页面时创建默认页面
- 新增、改名、调整尺寸、上移、下移和删除页面
- 设置进入运行态时的初始页面
- 页面名称和 ID 唯一
- 至少保留一个页面;初始页不能直接删除
- 仍被页面跳转控件引用的页面不能删除
- 缩小页面时不能让已有控件越界

### 建议阅读顺序

1. `HmiPage`:`app/src/domain/hmi_model.h/.cpp`
2. `HmiEditorService` 的页面方法:`app/src/services/hmi_editor_service.h/.cpp`
3. `ProjectWorkspaceController` 的页面操作
4. `PropertyPanelController::showPageProperties()` 及页面属性提交
5. `MainWindow` 中页面 QAction 的连接

### 重点服务方法

```text
ensureDefaultPage()
addPage()
renamePage()
resizePage()
movePage()
setInitialPage()
removePage()
```

### 阅读检查

- [ ] 能追踪“新增 HMI 页面”的完整调用链
- [ ] 能解释删除页面前检查初始页和页面跳转引用的原因
- [ ] 能解释页面排序为何会影响运行版页面顺序
- [ ] 能找到页面操作如何进入 HMI 撤销历史
- [ ] 查看 `hmi_editor_service_tests` 中的页面管理测试

---

## 9. 功能 7:HMI 控件编辑

### 功能范围

当前共有 8 类 HMI 控件:

| 控件 | 绑定/职责 |
| --- | --- |
| 按钮 `Button` | 固定绑定 M,写入 M 位 |
| 指示灯 `Indicator` | 固定绑定 M,只读显示 |
| 数值显示 `NumericDisplay` | 固定绑定 D,支持四种数值类型 |
| 数值输入 `NumericInput` | 固定绑定 D,支持四种数值类型和写入 |
| 文本 `Label` | 固定文字,不绑定寄存器 |
| 状态文本 `StatusText` | M OFF/ON 文本或 D 连续区间映射,只读 |
| 页面跳转 `PageJump` | 跳转到指定 HMI 页面 |
| 报警列表 `AlarmList` | 显示当前会话报警记录 |

共同编辑能力包括添加、自动分配绑定地址、选择、移动、调整尺寸、编辑文字和外观、删除、六方向批量对齐、撤销和重做。控件必须完整位于页面边界内,一次批量操作要么全部成功,要么完全不修改。

### 建议阅读顺序

1. `app/src/domain/hmi_control_registry.h/.cpp`
2. `HmiControl` 和各专用配置:`app/src/domain/hmi_model.h/.cpp`
3. `app/src/services/hmi_editor_service.h/.cpp`
4. `app/src/ui/hmi_editor_widget.h/.cpp`
5. `app/src/ui/property_panel_controller.h/.cpp`
6. `app/src/ui/status_text_dialog.ui/.h/.cpp`
7. `app/src/ui/button_extension_dialog.ui/.h/.cpp`
8. `docs/用户使用/HMI控件绑定说明.md`
9. `docs/用户使用/状态文本控件说明.md`
10. `docs/用户使用/按钮启用条件说明.md`

### 重点规则

- M 类和 D 类控件新增时,从该区域已占用的最大地址后自动分配
- 多字 D 控件按真实字数推进,并寻找合法对齐起点
- 属性面板只修改地址编号,按钮/指示灯区域固定为 M,普通数值控件固定为 D
- 状态文本在专用对话框选择 M 或 D
- D 状态区间使用 `[下限, 上限)`,必须连续覆盖整个数值域,最多 16 段
- 按钮支持置 ON、置 OFF、翻转、点动,以及可选的 M/D 启用条件
- 外观属性包括文字颜色、字号、粗体和斜体

### 阅读检查

- [ ] 能解释 8 类控件的描述为何集中在 `hmi_control_registry`
- [ ] 能追踪“添加数值输入”以及默认 D 地址分配
- [ ] 能追踪一次画布拖动如何调用服务并形成历史记录
- [ ] 能解释六种对齐的参考对象和原子失败行为
- [ ] 能解释状态文本、按钮扩展为何使用独立 Qt Designer 对话框
- [ ] 能找到属性面板针对不同控件隐藏/显示哪些字段
- [ ] 查看 `hmi_editor_service_tests` 的控件编辑、对齐、历史和四种 D 类型测试

---

## 10. 功能 8:HMI 运行交互与页面导航

### 功能范围

- 运行画布从当前活动寄存器仓库读取控件值
- 按钮按配置写 M;点动按钮按下写 1、释放写 0
- 按钮启用条件支持 M 期望值或 D 四类型六种比较
- 指示灯读取 M,数值显示读取 D,数值输入写 D
- 状态文本将 M 或 D 当前值映射为文字;无效、不可用和非有限数统一显示 `--`
- 页面跳转在运行会话内改变当前 HMI 页面
- 未运行、真机未首读、通信不可用或条件不满足时拒绝写入

### 建议阅读顺序

1. `app/src/services/hmi_runtime_service.h/.cpp`
2. `app/src/services/hmi_navigation_service.h/.cpp`
3. `app/src/ui/hmi_editor_widget.cpp` 中运行态绘制、鼠标事件和值刷新部分
4. `app/src/ui/runtime_monitor_widget.h/.cpp`
5. 回看 `app/src/domain/active_register_repository.h/.cpp`

### 重点调用链

```text
运行画布上的按钮事件
-> HmiRuntimeService::evaluateButtonEnabled()
-> HmiRuntimeService::operateButton()
-> ActiveRegisterRepository
-> VirtualRegisterRepository 或 PlcRegisterRepository
```

### 阅读检查

- [ ] 能解释按钮 `Pressed/Released` 对四种操作的不同处理
- [ ] 能解释 UI 置灰与服务层二次拒绝为什么都需要
- [ ] 能解释多字数值读取/写入如何转为连续 D 字
- [ ] 能解释状态文本为什么没有写入接口
- [ ] 能追踪页面跳转控件到 `HmiNavigationService::navigateTo()`
- [ ] 查看 `hmi_editor_service_tests` 中的 HMI 运行读写、状态文本和导航测试

---

## 11. 功能 9:报警、报警列表与地址注释

### 功能范围

- 报警定义支持 `MOn`、`MOff`、`DHigh`、`DLow`
- 报警配置支持新增、修改和删除
- 运行服务周期评估当前活动仓库,维护会话级报警记录
- 当前报警可以确认;条件解除后记录移除;记录不写入工程
- HMI 报警列表控件显示当前记录并支持确认
- M/D 地址注释是工程级元数据,可新增、更新和删除
- 地址注释不保存当前值,也不会单独让地址进入 PLC 轮询
- 梯形图网络注释属于行/网络功能,在第 11 章阅读

### 建议阅读顺序

1. `app/src/domain/alarm_model.h/.cpp`
2. `app/src/services/alarm_editor_service.h/.cpp`
3. `app/src/services/alarm_service.h/.cpp`
4. `app/src/ui/alarm_configuration_dialog.ui/.h/.cpp`
5. `app/src/services/register_comment_service.h/.cpp`
6. `app/src/ui/register_comment_dialog.ui/.h/.cpp`
7. `app/src/ui/runtime_monitor_widget.cpp` 和 HMI 报警列表投影

### 阅读检查

- [ ] 能解释四种报警条件和地址区域的匹配规则
- [ ] 能解释报警定义与 `AlarmRecord` 的生命周期差异
- [ ] 能解释已确认报警持续触发时为什么保留确认状态
- [ ] 能追踪报警列表上的确认操作
- [ ] 能解释地址注释为什么不进入寄存器仓库和 PLC 轮询
- [ ] 查看 `alarm_service_tests`,并查看工程管理测试中的报警/注释 JSON 往返

---

## 12. 功能 10:连续梯形图模型与逻辑组

### 功能范围

- 工程可包含多组有序控制逻辑,每组可以启用或禁用
- 每组逻辑由视觉行、固定 10 个条件格、独立输出槽和竖线组成
- 条件格类型为 `Gap`、`Wire` 或 `Node`
- 一段竖线只连接相邻两行的某个 `0~10` 列边界,长竖线由多段组成
- 网络不是单独对象,而是由横向导通与竖线连通关系计算出来
- `networkHeadIndex()` 是查找网络首行和网络注释归属的统一入口

### 建议阅读顺序

1. `app/src/domain/control_logic_model.h`
2. `app/src/domain/control_logic_model.cpp` 的结构校验和网络计算
3. `app/src/services/logic_editor_service.h` 的查询与逻辑组管理接口
4. `LogicEditorService` 中 `ensureDefaultLogic/addLogic/renameLogic/removeLogic/moveLogic/setLogicEnabled`
5. `ProjectWorkspaceController` 中控制逻辑操作
6. `docs/用户使用/连续梯形图网格说明.md`

### 核心对象关系

```text
ControlLogic
|- rungs[]
| |- cells[10]: Gap / Wire / Node
| `- output: optional LogicNode
`- verticalConnections[]: 相邻行之间的竖线段
```

### 阅读检查

- [ ] 能解释为什么 `LadderRung` 不是一个独立电气网络
- [ ] 能解释列边界 `0~10` 与 10 个条件格、输出槽的关系
- [ ] 能解释稳定 ID 和视觉顺序各自承担什么职责
- [ ] 能找到网络首行和网络连通分组的计算方式
- [ ] 能解释禁用的控制逻辑为何可以保留草稿但不参与运行扫描
- [ ] 查看 `domain_tests` 中连续网格和竖线可达性测试

---

## 13. 功能 11:梯形图画布与编辑操作

### 功能范围

- 新增行、上方/下方插入行、删除行
- 鼠标横向画线/删线、纵向画线/删线,拖动过程只显示预览,释放时一次提交
- 工具栏和快捷键逐格插入横线、竖线并自动推进光标
- 单击、Ctrl 追加、框选和行号整行选择
- 对节点、横线、输出和竖线进行原子批量删除
- 对普通对象片段或连续整行执行复制粘贴
- 编辑网络注释,并在网络合并/拆分时维护唯一归属
- 全部有效编辑支持撤销/重做;失败操作不产生脏状态和空历史

### 建议阅读顺序

1. `app/src/services/editor_history.h`
2. `app/src/services/logic_editor_service.h` 中行、线、选择、剪贴板和历史的数据结构
3. `app/src/services/logic_editor_service.cpp` 对应方法
4. `app/src/ui/logic_editor_widget.h/.cpp` 的布局、命中、选择、拖动预览和绘制
5. `app/src/ui/main_window.cpp` 中梯形图 QAction 和快捷键连接
6. `docs/用户使用/鼠标画线与删线说明.md`
7. `docs/用户使用/连续梯形图网格说明.md`

### 建议分四轮阅读

1. 行操作:`addRung/insertRungAbove/insertRungBelow/removeRung`
2. 线操作:`setHorizontalWireRange/setVerticalConnectionRange` 和两个 `apply*AndAdvance`
3. 选择与删除:`deleteSelection/removeNodes/removeVerticalConnections`
4. 剪贴板与历史:`copySelection/pasteClipboard/undo/redo`

### 阅读检查

- [ ] 能解释插入行如何拆分竖线、删除行何时合并上下竖线
- [ ] 能解释一次拖动为何只形成一条历史记录
- [ ] 能区分当前光标、对象选择和左侧行号整行选择
- [ ] 能解释普通片段的“透明空洞”和整行片段的差异
- [ ] 能解释粘贴冲突时如何恢复模型和工程修改状态
- [ ] 能解释两个带不同注释的网络为什么拒绝合并
- [ ] 查看 `logic_editor_service_tests` 的网格、行、竖线、注释、剪贴板和历史测试
- [ ] 查看 `runtime_panel_controller_tests` 的画布框选、对象/整行剪贴板和连续光标测试

---

## 14. 功能 12:梯形图指令、命令语与检查

### 当前指令能力

条件指令:

| 类型 | 语义 |
| --- | --- |
| 常开触点 | M 为 ON 时导通 |
| 常闭触点 | M 为 OFF 时导通 |
| 上升沿触点 | M 从 OFF 变 ON 的一次扫描脉冲 |
| 下降沿触点 | M 从 ON 变 OFF 的一次扫描脉冲 |
| D 比较 | D 的 Int16 值与常量执行六种比较 |

输出和数据指令:

| 类型 | 语义 |
| --- | --- |
| 普通线圈 OUT | 每次扫描把网络结果写入 M |
| 置位线圈 SET | 网络成立时将 M 置 ON |
| 复位线圈 RST | 网络成立时将 M 置 OFF |
| MOVE | 常量或 D 源写入一个 D 目标 |
| ADD | 两个常量/D 操作数相加,Int16 饱和 |
| SUB | 两个常量/D 操作数相减,Int16 饱和 |

编辑器还支持画布内命令语输入与补全:`LD/LDI/LDP/LDF`、比较、`AND/ANI`、`OR/ORI`、`OUT/SET/RST/MOV/ADD/SUB`。语法检查会规整不参与完整输出路径的残线并定位断路输出;双线圈检查是独立动作。

### 建议阅读顺序

1. `LogicNodeConfig` 各 variant:`app/src/domain/control_logic_model.h/.cpp`
2. `app/src/services/logic_command_service.h/.cpp`
3. `LogicEditorService` 的节点、输出、并联、语法检查和双线圈方法
4. `app/src/ui/logic_instruction_dialog.ui/.h/.cpp`
5. `app/src/ui/logic_editor_widget.cpp` 中内嵌命令输入部分
6. `docs/用户使用/命令语输入说明.md`

### 阅读检查

- [ ] 能说明为什么不同指令配置使用 `std::variant` 独立类型
- [ ] 能从一条 `LD M0` 命令追到条件格里的 `ContactNodeConfig`
- [ ] 能从 `ADD D0 K1 D1` 追到 `ArithmeticNodeConfig`
- [ ] 能解释输出提交时的自动补尾线和自动追加下一空行
- [ ] 能解释显式中间 `Gap` 为什么不会被自动补通
- [ ] 能解释普通语法检查和双线圈检查为什么分开
- [ ] 查看 `logic_editor_service_tests` 中连续输入、并联、语法规整和双线圈测试

---

## 15. 功能 13:软件逻辑执行与离线仿真

### 功能范围

- 按控制逻辑顺序和视觉行顺序执行所有已启用逻辑
- 每列边界先合并竖线连通分量,再向右传播电源
- 同一扫描中,前面输出写入的 M/D 对后续网络立即可见
- 边沿触点按“逻辑 ID + 节点 ID”保存跨扫描状态
- 普通、SET、RST 线圈及 MOVE、ADD、SUB 按各自语义执行
- ADD/SUB 超出 Int16 时饱和并记录 overflow,不让整轮扫描中止
- 生成网格输入/输出、节点、竖线、行末和数据指令结果的完整轨迹
- 离线服务复制逻辑快照,按 50 ms 默认周期执行;失败进入 Faulted
- 每次新离线会话清空上一轮运行值并恢复编辑态确认的离线初始值

### 建议阅读顺序

1. `app/src/services/software_logic_executor.h/.cpp`
2. `app/src/services/offline_simulation_service.h/.cpp`
3. `app/src/domain/virtual_register_repository.h/.cpp`
4. `app/src/ui/logic_editor_widget.cpp` 中运行轨迹绘制
5. `app/src/ui/runtime_panel_controller.cpp` 中离线刷新
6. `app/tests/offline_simulation_service_tests.cpp`

### 重点调用链

```text
RuntimeModeService::enterOfflineRunning()
-> 运行前工程校验/语法检查
-> OfflineSimulationService::start(logic snapshot)
-> QTimer
-> SoftwareLogicExecutor::executeScan()
-> VirtualRegisterRepository
-> LogicTraceSnapshot
-> RuntimePanelController -> LogicEditorWidget
```

### 阅读检查

- [ ] 能手推一个常开触点驱动普通线圈的两轮扫描
- [ ] 能手推上升沿和下降沿触点的跨扫描状态
- [ ] 能解释并联竖线网络如何按列传播电源
- [ ] 能解释前面网络输出为何能影响后面网络
- [ ] 能解释扫描失败如何停止定时器并保存错误定位
- [ ] 能解释轨迹为何按逻辑 ID 分区,最后只在画布投影一次
- [ ] 查看 `offline_simulation_service_tests` 的扫描语义、初始值、轨迹和故障测试

---

## 16. 功能 14:数据监控

### 功能范围

- 主窗口编辑态提供独立“数据监控”页签
- 运行窗口也复用同一会话级监控列表
- 批量添加 M/D 地址,去重、删除、清空和单点写入
- M 按 ON/OFF 读取;D 支持四种数据类型和连续多字写入
- 监控列表最多 64 行;加入工程轮询后还受 256 个去重地址限制
- 编辑态明确选择“离线 M/D”或“真机 PLC M/D”,连接 PLC 不自动切换
- 切到真机要求已连接并首读;断线后保持真机选择但显示不可用并禁写
- 离线编辑值同步保存为当前进程的下一次离线仿真初始值
- 运行态数据源由运行模式决定,不受编辑态选择影响

### 建议阅读顺序

1. `app/src/domain/register_monitor_model.h/.cpp`
2. `app/src/services/register_monitor_service.h/.cpp`
3. `app/src/ui/free_monitor_widget.ui/.h/.cpp`
4. `app/src/ui/main_window.cpp` 中数据监控配置与刷新
5. `app/src/ui/runtime_monitor_widget.cpp` 中运行监控接入
6. `docs/用户使用/数据监控与离线初始值说明.md`
7. `app/tests/register_monitor_service_tests.cpp`

### 阅读检查

- [ ] 能解释监控列表为什么不属于 `Project`、不写入 JSON
- [ ] 能解释批量添加四种 D 类型时步长分别是多少
- [ ] 能解释一个 Float64 监控点为什么消耗 4 个 PLC 轮询地址
- [ ] 能追踪编辑态写离线值如何同时更新初始值仓库
- [ ] 能解释选择真机后断线为什么不能静默回退并显示离线数据
- [ ] 能解释多字真机写入为何必须使用一次连续寄存器请求
- [ ] 查看 `register_monitor_service_tests` 的地址、数据类型、初始值和数据源切换测试

---

## 17. 功能 15:PLC 配置、搜索与 Modbus RTU

### 功能范围

- 串口参数:端口、站号、波特率、数据位、校验位、停止位、超时、重试和轮询周期
- PLC 配置对话框列出串口并支持自动搜索
- 自动搜索使用独立串口会话,不污染正式连接状态机
- `QModbusRtuSerialMaster` 异步连接,不阻塞 UI
- 汇总 HMI、报警、梯形图和自由监控引用,排序、去重、分区并合并读块
- 单次最多 120 个数据项,一轮最多 8 块、256 个去重地址
- 多字 D 范围不能在轮询拆块边界中间被切开
- M 写单线圈;D Int16 写单寄存器;多字 D 使用一次多寄存器写入
- 写请求不乐观更新缓存,等待后续轮询读回
- 处理未响应、超时、协议错误、USB 串口拔出、异常断开和恢复首读

### 建议阅读顺序

1. `app/src/services/plc_communication_gateway.h`
2. `app/src/infrastructure/plc_register_repository.h/.cpp`
3. `app/src/infrastructure/plc_communication_error_classifier.h/.cpp`
4. `app/src/infrastructure/plc_communication_service.h/.cpp`
5. `app/src/services/plc_discovery_gateway.h/.cpp`
6. `app/src/infrastructure/plc_discovery_service.h/.cpp`
7. `app/src/ui/plc_connection_dialog.ui/.h/.cpp`
8. `docs/architecture.md` 的“Modbus RTU 通信”
9. `docs/XDH-60T4-E指令与Modbus要点.md`

### 通信数据流

```text
PLC
<-> PlcCommunicationService(异步 Modbus RTU、轮询、恢复)
<-> PlcRegisterRepository(最新读回缓存、写请求转交)
<-> ActiveRegisterRepository
<-> HMI / 数据监控
```

### 阅读检查

- [ ] 能解释 `PlcCommunicationGateway` 为什么放在 services 而具体 Qt 实现在 infrastructure
- [ ] 能追踪工程引用地址如何汇总为轮询块
- [ ] 能解释一次完整轮询与“首读完成”的判定
- [ ] 能解释缓存失效后为什么撤销真机运行资格
- [ ] 能解释恢复状态与重新连接的差异
- [ ] 能解释写请求排队和后续轮询确认
- [ ] 查看 `plc_connection_dialog_tests` 的端口刷新和自动搜索流程
- [ ] 查看 `plc_runtime_tests` 的缓存、拆块、多字写入、错误恢复和 Fake gateway 测试

> 真机代码阅读不等于真机操作。需要实际写 PLC 时,必须按项目约定先读原值、在 STOP 状态验证、测试后恢复并再次读回。

---

## 18. 功能 16:运行模式与运行监控窗口

### 功能范围

- 三种模式:编辑态、离线运行、真机运行
- 离线和真机不能直接切换,必须先经过编辑态
- 进入任何运行态前执行工程运行校验和已启用逻辑语法检查
- 离线使用虚拟仓库并运行软件扫描
- 真机 HMI/监控使用 PLC 缓存;本地梯形图只在临时虚拟仓库推算轨迹
- 真机本地输出绝不写回 PLC,下一轮重新从最新 PLC 缓存开始
- 普通编程器运行时打开唯一顶层运行监控窗口,主窗口保留在后面
- 运行大屏组合 HMI、当前逻辑轨迹、自由监控、报警和模式切换
- 关闭运行大屏的系统关闭路径被忽略,必须通过“返回编辑”退出运行会话

### 建议阅读顺序

1. `app/src/domain/runtime_state.h/.cpp`
2. `app/src/services/runtime_mode_service.h/.cpp`
3. `app/src/services/online_logic_monitor_service.h/.cpp`
4. `app/src/ui/runtime_monitor_window.ui/.h/.cpp`
5. `app/src/ui/runtime_monitor_widget.ui/.h/.cpp`
6. `app/src/ui/runtime_panel_controller.h/.cpp`
7. `app/src/ui/main_window.cpp` 的 `requestMode()` 和 `updateModeUi()`
8. 回看 `docs/architecture.md` 的“数据源与运行模式”

### 模式矩阵

| 模式 | 工程编辑 | HMI/监控数据源 | 本地逻辑 |
| --- | --- | --- | --- |
| 编辑态 | 允许 | 数据监控由用户显式选离线或 PLC | 停止 |
| 离线运行 | 禁止 | 虚拟 M/D | 执行并写虚拟 M/D |
| 真机运行 | 禁止 | PLC 读回缓存 | 用缓存副本推算,只写临时仓库 |

### 阅读检查

- [ ] 能画出三种模式的合法状态转换图
- [ ] 能解释 `ModePolicy` 如何同时约束编辑、数据源和执行器
- [ ] 能追踪一次“进入真机运行”的所有前置检查
- [ ] 能解释 `OnlineLogicMonitorService::copyPlcSnapshot()` 的安全意义
- [ ] 能解释为什么本地绿色轨迹不是 PLC 内部真实程序轨迹
- [ ] 能解释运行监控窗口为何只允许存在一个实例
- [ ] 查看 `runtime_mode_service_tests` 的仓库切换、首读和断路工程拦截测试
- [ ] 查看 `runtime_panel_controller_tests` 的轨迹投影、排队回调和运行窗口测试

---

## 19. 功能 17:用户运行程序导出与启动

### 功能范围

- 从当前工程复制一份 HMI 运行快照
- 只保留 HMI 页面、报警和导航数据,移除控制逻辑和地址注释
- 对裁剪后的工程执行运行校验并写入临时 JSON
- 把 JSON 追加到已编译 exe 尾部,保存魔数、版本、长度和 SHA-256
- 复制 Qt、MinGW 运行库、平台插件、样式和最小 `runtime.ini`
- 导出使用当前已编译 exe 作为模板,不调用 qmake 或编译器
- 用户运行版启动时从自身尾部加载工程,只显示 HMI
- 自动按 `runtime.ini` 连接真实 PLC,首读完成后才放行 HMI 写入
- 串口拔出、PLC 无响应或超时后自动重连并重新首读;恢复期间不切到虚拟数据

### 建议阅读顺序

1. `ProjectService::exportHmiRuntimeAs()`:`app/src/services/project_service.cpp`
2. `app/src/infrastructure/runtime_project_bundle.h/.cpp`
3. `app/src/infrastructure/runtime_settings_loader.h/.cpp`
4. `app/src/ui/main_window.cpp` 的 `exportRuntimeProgram()` 及辅助流程
5. `app/src/main.cpp` 的封装工程识别和临时加载流程
6. `MainWindow` 的用户运行版构造、自动连接和重连定时器
7. `scripts/package_qt_app.ps1`,理解编程器发布打包与“导出运行版”的区别
8. `docs/用户使用/用户运行程序导出说明.md`

### 重点调用链

```text
编程器导出
-> ProjectService::exportHmiRuntimeAs()
-> JsonProjectStorage
-> RuntimeProjectBundleService::write()
-> 复制运行依赖和 runtime.ini

导出 exe 启动
-> RuntimeProjectBundleService::load(current exe)
-> RuntimeSettingsLoader::load()
-> ProjectService::load(temporary json)
-> HMI-only MainWindow
-> 自动连接 PLC -> 首读 -> 开放 HMI 写入
```

### 阅读检查

- [ ] 能解释为什么运行版不包含控制逻辑和地址注释
- [ ] 能解释封装完整性检查能发现哪些损坏
- [ ] 能区分维护者发布编程器与工程师导出用户运行版
- [ ] 能解释运行版为什么绝不能提供离线模式回退
- [ ] 能追踪启动自动连接、故障重连和重新首读
- [ ] 查看 `runtime_project_bundle_tests` 和 `runtime_settings_tests`
- [ ] 查看 `project_management_tests` 中 HMI 运行快照裁剪和校验测试

---

## 20. 最后统一看测试、构建和示例工程

这部分不是第 18 个业务功能,而是看完 17 个功能后用于串联全项目。

### 自动化测试对应关系

| 测试目标 | 主要覆盖 |
| --- | --- |
| `domain_tests` | 地址、模型、边界、编解码和梯形图可达性 |
| `application_settings_tests` | 普通编程器配置 |
| `runtime_settings_tests` | 用户运行版最小配置 |
| `alarm_service_tests` | 报警定义和运行记录 |
| `hmi_editor_service_tests` | HMI 页面、控件、历史、导航和运行读写 |
| `logic_editor_service_tests` | 连续梯形图全部编辑用例 |
| `offline_simulation_service_tests` | 软件扫描、离线仿真和真机缓存副本推算 |
| `project_management_tests` | JSON、工程服务和非法文件 |
| `register_monitor_service_tests` | 数据监控和离线初始值 |
| `runtime_project_bundle_tests` | exe 工程封装 |
| `runtime_mode_service_tests` | 三种模式和仓库切换 |
| `runtime_panel_controller_tests` | 运行窗口、轨迹投影和关键 UI 交互 |
| `plc_connection_dialog_tests` | PLC 配置与自动搜索 UI |
| `plc_runtime_tests` | PLC 缓存、轮询、写入和恢复状态机 |

### 建议最后执行

```powershell
pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release
pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release -Suite Performance
pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1
git diff --check
```

### 最终串联练习

- [ ] 打开 `json/motor_forward_reverse.json`,从 JSON 找到 HMI 控件、报警和梯形图对象
- [ ] 从 HMI 按钮的 M 绑定追到离线扫描中的触点和线圈
- [ ] 在数据监控中写离线初始值,追踪到下一次离线会话恢复
- [ ] 从进入真机运行追踪轮询地址收集、PLC 缓存和本地轨迹副本
- [ ] 从导出菜单追踪到裁剪 JSON、exe 封装和运行版自动连接
- [ ] 能不看文档画出完整依赖方向:`UI -> Services -> Domain`,`Infrastructure -> Services contracts + Domain`

---

## 21. 当前明确不包含的功能

阅读时遇到下面内容,不要继续在代码里找,因为当前项目明确不实现:

- 不生成、编译或下载 PLC 程序
- 不读取 PLC 内部程序或真实网络轨迹
- 真机模式的本地梯形图结果不写回 PLC
- 不实现完整 XDPPro 指令集
- 当前没有 T/C 触点、TON、CTU、CTD
- 不支持任意像素自由画线;梯形图只能使用固定网格和相邻行竖线
- 不兼容旧工程 JSON `1.0/2.0/3.0`
- 数据监控列表和离线初始值不持久化到工程
- 报警运行记录不持久化到工程
- 应用配置只在启动时读取,不热更新

---

## 22. 阅读进度总表

每完成一章,建议在“备注”里写下仍没想明白的问题或关键调用链,后续回看会很省时间。

| 完成 | 章节 | 备注 |
| --- | --- | --- |
| [ ] | 1. 程序启动与应用配置 | |
| [ ] | 2. M/D 寄存器公共模型 | |
| [ ] | 3. 工程聚合、数量边界与校验 | |
| [ ] | 4. 工程新建、保存、加载与 JSON | |
| [ ] | 5. 主窗口、工程树与编辑会话 | |
| [ ] | 6. HMI 页面管理 | |
| [ ] | 7. HMI 控件编辑 | |
| [ ] | 8. HMI 运行交互与页面导航 | |
| [ ] | 9. 报警、报警列表与地址注释 | |
| [ ] | 10. 连续梯形图模型与逻辑组 | |
| [ ] | 11. 梯形图画布与编辑操作 | |
| [ ] | 12. 梯形图指令、命令语与检查 | |
| [ ] | 13. 软件逻辑执行与离线仿真 | |
| [ ] | 14. 数据监控 | |
| [ ] | 15. PLC 配置、搜索与 Modbus RTU | |
| [ ] | 16. 运行模式与运行监控窗口 | |
| [ ] | 17. 用户运行程序导出与启动 | |

全部完成后,你应该能够从任意一个界面动作出发,快速判断它属于哪个控制器、调用哪个服务、修改哪个领域对象、是否经过基础设施,以及由哪个测试证明。

+ 1
- 1
docs/测试约定.md 查看文件

@@ -26,7 +26,7 @@
| `project_management_tests` | JSON 往返、支路隐藏注释等非法文件和工程保存状态 |
| `register_monitor_service_tests` | 监视地址、四种 D 类型读写、离线初始值捕获、编辑态数据源显式切换与断线保持、批量步长、重叠提示和运行态活动仓库 |
| `runtime_project_bundle_tests` | 用户运行程序封装的写入、读取、校验和损坏拒绝 |
| `runtime_mode_service_tests` | 编辑、离线、真机只读轨迹、仓库切换和断路工程启动拦截 |
| `runtime_mode_service_tests` | 编辑、离线、真机只读轨迹、仓库切换、断路工程启动拦截和用户运行版故障恢复策略 |
| `runtime_panel_controller_tests` | 完整执行器快照经过控制器和运行窗口后的轨迹投影、排队回调、画布框选、对象/行号剪贴板和连续输入 |
| `plc_connection_dialog_tests` | PLC 端口刷新及自动搜索开始、取消、进度和参数回填 |
| `plc_runtime_tests` | PLC 缓存、完整轮询通知、多字范围拆块、一次多寄存器写入、错误恢复和 Fake gateway |


+ 3
- 1
docs/用户使用/用户运行程序导出说明.md 查看文件

@@ -31,7 +31,9 @@

## 用户操作

打开导出的 exe 后直接显示 HMI 页面,程序自动读取同目录 `config/runtime.ini` 并连接真实 PLC。运行版不显示模式切换、PLC 配置、自由监控和梯形图控件;HMI 按钮和输入控件只有在 PLC 连接成功并完成首次读取后才允许写入。串口设备被拔出、PLC 端口无响应或通信超时后,运行版会自动重新连接并重新完成首读,恢复期间 HMI 保持只读。
打开导出的 exe 后直接显示 HMI 页面,程序自动读取同目录 `config/runtime.ini` 并连接真实 PLC。运行版不显示模式切换、PLC 配置、自由监控和梯形图控件;HMI 按钮和输入控件只有在 PLC 连接成功并完成首次读取后才允许写入。USB 转串口被拔出后,运行版会定时重新打开配置的串口;本地串口仍正常但 PLC 端口无响应或通信超时后,程序保持串口打开并定时探测 PLC。两种情况恢复后都会重新完成全部地址首读,恢复期间 HMI 保持只读。

同一次通信故障只提示一次,后台重试失败或错误原因变化不会继续叠加弹窗;串口重新打开但 PLC 尚未完成首读时仍属于同一次故障。提示出现后不需要重启程序,检查设备和接线并等待自动恢复即可。只有 PLC 重新连接并完成首读后,后续新的断线才会再次提示。

运行版配置文件只保留串口字段:



+ 3
- 0
注释标准.md 查看文件

@@ -0,0 +1,3 @@
- h 文件除了构造函数和虚构函数,剩下的函数附上单行注释,剩下的字段也要附上单行注释
- cpp 文件每个函数实现的上方附加单行注释,并且涉及到新特性的或者C++,Qt关键 API 或者难懂的代码也要附上关键注释
- 注释的文本要通俗易懂

正在加载...
取消
保存