From 074c32b3bb4d9d111a9a8b648dcc0763e0e3cf1b Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Wed, 12 Aug 2026 08:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20HMI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BA=A4=E4=BA=92=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- app/src/domain/hmi_model.h | 10 ++--- app/src/domain/project_model.cpp | 8 +--- app/src/domain/register_address.cpp | 2 + app/src/domain/runtime_state.cpp | 2 + app/src/domain/runtime_state.h | 27 +++++++++++-- app/src/main.cpp | 2 + app/src/services/hmi_editor_service.cpp | 15 +++++++- app/src/services/hmi_editor_service.h | 29 ++++++++++++++ app/src/services/hmi_runtime_service.cpp | 4 ++ app/src/services/hmi_runtime_service.h | 10 ++--- app/src/services/project_service.cpp | 8 +++- app/src/services/runtime_mode_service.cpp | 2 + app/src/services/runtime_mode_service.h | 31 ++++++++++++++- app/src/ui/hmi_editor_widget.cpp | 47 +++++++++++++++++++++++ app/src/ui/hmi_editor_widget.h | 11 ++++++ app/src/ui/main_window.cpp | 36 +++++++++++++++-- app/src/ui/main_window.h | 14 +++++++ app/tests/domain_tests.cpp | 7 ++++ app/tests/hmi_editor_service_tests.cpp | 4 ++ app/tests/main_window_tests.cpp | 4 ++ app/tests/project_management_tests.cpp | 7 ++++ app/tests/runtime_mode_service_tests.cpp | 2 + 23 files changed, 259 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 563d486..8584390 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,6 @@ Desktop.ini /scripts /docs/images /docs/类图Mermaid代码.md -/other \ No newline at end of file +/other +设计方案书.doc +/学习 \ No newline at end of file diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index a400b61..5ca5473 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -37,11 +37,11 @@ struct HmiRect */ enum class HmiControlType { - Button, - Indicator, - NumericDisplay, - NumericInput, - Label + Button, // 按钮 + Indicator, // 指示灯 + NumericDisplay, // 数值显示 + NumericInput, // 数值输入 + Label // 标签 }; /** diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 881c409..fbac748 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -12,12 +12,7 @@ void setError(std::string *error, const std::string &message) } } -/** - * @brief 检查对象集合中是否存在重复 id - * @tparam TItem 包含 id 成员的对象类型 - * @param items 需要检查的对象集合 - * @return 存在重复 id 时返回 true 否则返回 false - */ +// 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验 template bool containsDuplicateId(const std::vector &items) { @@ -59,6 +54,7 @@ bool Project::validate(std::string *error) const } for (const HmiPage &page : hmiPages) { + // 工程聚合校验会向下委托页面和控件的完整规则 if (!page.validate(error)) { return false; diff --git a/app/src/domain/register_address.cpp b/app/src/domain/register_address.cpp index d15d2f6..96c2098 100644 --- a/app/src/domain/register_address.cpp +++ b/app/src/domain/register_address.cpp @@ -4,6 +4,7 @@ namespace { bool isSupportedArea(RegisterArea area) { + // 地址值对象只承认项目定义的 M 区和 D 区 return area == RegisterArea::M || area == RegisterArea::D; } @@ -43,6 +44,7 @@ std::string RegisterAddress::toString() const areaName = "D"; break; default: + // 未知枚举值不能伪装成有效 PLC 地址 return "InvalidRegisterAddress"; } diff --git a/app/src/domain/runtime_state.cpp b/app/src/domain/runtime_state.cpp index 93cb829..429eae4 100644 --- a/app/src/domain/runtime_state.cpp +++ b/app/src/domain/runtime_state.cpp @@ -12,6 +12,7 @@ ModePolicy RuntimeState::policy() const ModeTransitionResult RuntimeState::enterEditing() { + // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 if (mode_ == ApplicationMode::Editing) { return {false, ModeTransitionError::AlreadyInRequestedMode}; @@ -46,6 +47,7 @@ ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_comp } if (!initial_plc_read_completed) { + // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 return {false, ModeTransitionError::InitialPlcReadRequired}; } mode_ = ApplicationMode::OnlineRunning; diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h index f392aeb..81333f0 100644 --- a/app/src/domain/runtime_state.h +++ b/app/src/domain/runtime_state.h @@ -30,15 +30,36 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) { case ApplicationMode::Editing: { - return {true, false, false, false, false}; + // 编辑态只允许修改工程,不连接寄存器,也不执行软件逻辑 + return { + true, // allowsProjectEditing 允许编辑工程 + false, // usesVirtualRegisters 不使用虚拟寄存器 + false, // usesPlcRegisters 不使用 PLC 寄存器 + false, // runsLogicExecutor 不运行软件逻辑执行器 + false // requiresInitialPlcRead 不要求 PLC 初次读取 + }; } case ApplicationMode::OfflineRunning: { - return {false, true, false, true, false}; + // 离线运行态禁止改工程,使用虚拟 M/D 并运行软件逻辑 + return { + false, // allowsProjectEditing 禁止编辑工程 + true, // usesVirtualRegisters 使用虚拟寄存器 + false, // usesPlcRegisters 不使用 PLC 寄存器 + true, // runsLogicExecutor 运行软件逻辑执行器 + false // requiresInitialPlcRead 不要求 PLC 初次读取 + }; } case ApplicationMode::OnlineRunning: { - return {false, false, true, false, true}; + // 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC + return { + false, // allowsProjectEditing 禁止编辑工程 + false, // usesVirtualRegisters 不使用虚拟寄存器 + true, // usesPlcRegisters 使用 PLC 寄存器 + false, // runsLogicExecutor 不运行软件逻辑执行器 + true // requiresInitialPlcRead 必须先完成 PLC 初次读取 + }; } default: { diff --git a/app/src/main.cpp b/app/src/main.cpp index 61bac5a..69178e2 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -21,9 +21,11 @@ int main(int argc, char *argv[]) application.setApplicationName(QObject::tr("综合平台编程器")); application.setOrganizationName(QStringLiteral("QtProXinJe")); + // 组合根负责创建具体实现并将抽象依赖注入服务和 UI JsonProjectStorage project_storage; ProjectService project_service(project_storage); HmiEditorService hmi_editor_service(project_service); + // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 VirtualRegisterRepository virtual_register_repository; HmiRuntimeService hmi_runtime_service(virtual_register_repository); RuntimeModeService runtime_mode_service; diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 3cfb040..1c18f1d 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -83,6 +83,7 @@ HmiEditorService::HmiEditorService(ProjectService &project_service) { } +// 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 const HmiPage *HmiEditorService::findPage(const std::string &page_id) const { const Project &project = project_service_.project(); @@ -96,6 +97,7 @@ const HmiPage *HmiEditorService::findPage(const std::string &page_id) const return page == project.hmiPages.cend() ? nullptr : &*page; } +// 先找到页面 → 在页面内查找指定控件;页面不存在 / 控件不存在都返回 nullptr const HmiControl *HmiEditorService::findControl( const std::string &page_id, const std::string &control_id) const { @@ -114,12 +116,14 @@ const HmiControl *HmiEditorService::findControl( return control == page->controls.cend() ? nullptr : &*control; } +// 获取第一个页面 ID;没有页面返回空字符串 std::string HmiEditorService::firstPageId() const { const Project &project = project_service_.project(); return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id; } +// 保证工程至少存在一个 HMI 页面 HmiEditorResult HmiEditorService::ensureDefaultPage() { if (!project_service_.project().hmiPages.empty()) @@ -127,6 +131,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() return {true, HmiEditorError::None, {}, firstPageId()}; } + // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 HmiPage page; page.id = "page-1"; page.name = "主操作页面"; @@ -145,6 +150,7 @@ HmiEditorResult HmiEditorService::addControl( return failure(HmiEditorError::PageNotFound, "HMI page was not found"); } + // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 HmiControl control = makeControl(*page, type); Project &project = project_service_.editProject(); auto target_page = std::find_if( @@ -193,6 +199,7 @@ HmiEditorResult HmiEditorService::removeControl( return {true, HmiEditorError::None, {}, control_id}; } +// 移动 / 缩放控件 HmiEditorResult HmiEditorService::moveControl( const std::string &page_id, const std::string &control_id, @@ -209,8 +216,10 @@ HmiEditorResult HmiEditorService::moveControl( return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); } + // 先校验候选位置,失败时不修改工程模型 HmiControl candidate = *control; candidate.bounds = bounds; + // 更新前保留原控件,只有全部编辑规则通过才覆盖原值 std::string error; if (!validateEditableControl(*page, candidate, &error)) { @@ -228,6 +237,7 @@ HmiEditorResult HmiEditorService::moveControl( return {true, HmiEditorError::None, {}, control_id}; } +// 更新控件全部属性 HmiEditorResult HmiEditorService::updateControl( const std::string &page_id, const std::string &control_id, @@ -327,10 +337,11 @@ 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; + return item.id != excluded_id && item.id == candidate_id; // 如果这个控件不是正在编辑的原控件,并且它的 ID 等于准备使用的新 ID }); } +// 根据控件类型生成默认样式、文字、大小、初始坐标 HmiControl HmiEditorService::makeControl( const HmiPage &page, HmiControlType type) { @@ -346,9 +357,11 @@ HmiControl HmiEditorService::makeControl( return control; } +// 生成页面内不重复控件 ID,前缀 + 自增数字 std::string HmiEditorService::makeUniqueId( const HmiPage &page, const std::string &prefix) { + // 同类控件从 1 递增命名,保证页面内标识稳定且唯一 int suffix = 1; while (true) { diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h index 800902a..48d6ae5 100644 --- a/app/src/services/hmi_editor_service.h +++ b/app/src/services/hmi_editor_service.h @@ -118,18 +118,47 @@ public: const HmiControl &control); private: + /** + * @brief 校验控件编辑后仍满足页面内的基础约束 + * @param page 控件所属页面 + * @param control 待校验控件 + * @param error 校验失败时写入原因,可为 nullptr + * @return 控件标识、尺寸、位置和寄存器绑定区域均有效时返回 true + */ static bool validateEditableControl( const HmiPage &page, const HmiControl &control, std::string *error); + /** + * @brief 检查候选控件标识是否与页面内其他控件重复 + * @param page 待检查页面 + * @param excluded_id 更新场景中需要排除的原控件标识 + * @param candidate_id 待使用的控件标识 + * @return 存在同标识的其他控件时返回 true + */ static bool hasDuplicateControlId( const HmiPage &page, const std::string &excluded_id, const std::string &candidate_id); + /** + * @brief 按控件类型创建带默认属性的新控件 + * @param page 新控件所属页面,用于确定初始位置和唯一标识 + * @param type 新控件类型 + * @return 未绑定寄存器的默认控件配置 + */ static HmiControl makeControl( const HmiPage &page, HmiControlType type); + /** + * @brief 在页面内生成带指定前缀的唯一控件标识 + * @param page 待检查页面 + * @param prefix 控件类型对应的标识前缀 + * @return 从 1 开始递增且不与现有控件重复的标识 + */ static std::string makeUniqueId( const HmiPage &page, const std::string &prefix); + /** + * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 + */ ProjectService &project_service_; }; diff --git a/app/src/services/hmi_runtime_service.cpp b/app/src/services/hmi_runtime_service.cpp index 38751a8..a527c08 100644 --- a/app/src/services/hmi_runtime_service.cpp +++ b/app/src/services/hmi_runtime_service.cpp @@ -32,6 +32,7 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const { + // 标签等静态控件不参与寄存器读写 if (!isBitControl(control.type) && !isWordControl(control.type)) { return readFailure(HmiRuntimeError::UnsupportedControl); @@ -46,6 +47,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c } if (isBitControl(control.type)) { + // 按控件类型选择仓库的 M 位读取契约 const BitReadResult result = repository_.readBit(*control.binding); if (!result.succeeded) { @@ -76,6 +78,7 @@ HmiRuntimeWriteResult HmiRuntimeService::toggleButton(const HmiControl &control) { return writeFailure(current.error); } + // 先读后取反实现按钮点击切换 M 位语义 const RegisterWriteResult result = repository_.writeBit( *control.binding, !current.bit_value); return result.succeeded @@ -106,6 +109,7 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error) { + // 领域仓库错误被收敛为 HMI 可理解的运行错误 switch (error) { case RegisterError::InvalidAddress: diff --git a/app/src/services/hmi_runtime_service.h b/app/src/services/hmi_runtime_service.h index b906579..0b12814 100644 --- a/app/src/services/hmi_runtime_service.h +++ b/app/src/services/hmi_runtime_service.h @@ -17,11 +17,11 @@ */ enum class HmiRuntimeError { - None, - UnsupportedControl, - MissingBinding, - InvalidBinding, - RepositoryFailure + None, // 无错误 + UnsupportedControl, // 控件类型不支持寄存器读写 + MissingBinding, // 控件未配置寄存器绑定 + InvalidBinding, // 控件绑定地址无效或地址区域不匹配 + RepositoryFailure // 寄存器仓库读取或写入失败 }; /** diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index 259366b..9fdaab8 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -9,7 +9,7 @@ namespace { -// 使用高精度时间戳和随机值生成工程标识 +// 生成唯一工程 ID:project-时间戳-随机数 std::string generateProjectId() { const auto timestamp = static_cast( @@ -51,6 +51,7 @@ bool ProjectService::hasCurrentFile() const return !current_file_path_.empty(); } +// 查询是否存在未保存修改 bool ProjectService::isModified() const { return modified_; @@ -82,6 +83,7 @@ ProjectOperationResult ProjectService::save() ProjectStorageError::None, "project file path is required"}; } + // 复用另存为流程,确保两种保存方式拥有相同的校验和错误处理 return saveAs(current_file_path_); } @@ -149,15 +151,18 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; } +// 创建一个全新空白工程实例 Project ProjectService::makeNewProject(const std::string &name) { Project project; project.metadata.id = generateProjectId(); project.metadata.name = name; + // 新工程固定使用当前存储格式版本 project.metadata.formatVersion = "1.0"; return project; } +// 判断字符串是不是空白:空字符串 / 全是空格、制表符都算空白 bool ProjectService::isBlank(const std::string &value) { return value.empty() @@ -170,6 +175,7 @@ bool ProjectService::isBlank(const std::string &value) }); } +// 包装存储层失败结果 ProjectOperationResult ProjectService::storageFailure( ProjectStorageError error, const std::string &message) { diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 44c14b4..c3f8853 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -30,11 +30,13 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() ModeTransitionResult RuntimeModeService::enterOnlineRunning() { + // 首次 PLC 读取状态由服务维护,再交给领域状态机统一裁决 return state_.enterOnlineRunning(initial_plc_read_completed_); } void RuntimeModeService::setInitialPlcReadCompleted(bool completed) { + // 通信服务完成有效读回后才允许把此标志设为 true initial_plc_read_completed_ = completed; } diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index b0b7372..f3d744b 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -14,15 +14,44 @@ class RuntimeModeService { public: + /** + * @brief 获取当前应用运行模式 + * @return 当前的编辑、离线运行或真机运行模式 + */ ApplicationMode mode() const; + /** + * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 + * @return 与当前运行模式对应的能力策略 + */ ModePolicy policy() const; + /** + * @brief 退出离线或真机运行态并返回编辑态 + * @return 已处于编辑态或状态机拒绝切换时返回失败结果 + */ ModeTransitionResult enterEditing(); + /** + * @brief 从编辑态进入离线运行态 + * @return 非编辑态进入时返回必须先回到编辑态的失败结果 + */ ModeTransitionResult enterOfflineRunning(); + /** + * @brief 从编辑态进入真机运行态 + * @return 未完成 PLC 初次读取或非编辑态进入时返回失败结果 + */ ModeTransitionResult enterOnlineRunning(); - // 由后续 PLC 通信服务在首次读取成功或缓存失效时更新 + /** + * @brief 更新 PLC 是否已完成首次有效读取 + * @param completed 为 true 时允许在编辑态进入真机运行模式 + * + * 由 PLC 通信服务在首次读取成功或缓存失效时调用 + */ void setInitialPlcReadCompleted(bool completed); + /** + * @brief 查询 PLC 是否已完成首次有效读取 + * @return 已建立可用于进入真机运行态的 PLC 缓存时返回 true + */ bool initialPlcReadCompleted() const; private: diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 860ab95..6439758 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -20,9 +20,11 @@ namespace { +// 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 class HmiGraphicsItem final : public QGraphicsItem { public: + // 保存控件快照、页面边界和回调,使图元不直接依赖服务层 HmiGraphicsItem( const HmiControl &control, int page_width, @@ -35,11 +37,17 @@ public: moved_(std::move(moved)), activated_(std::move(activated)) { + // 领域坐标直接作为图元在场景中的初始位置 setPos(control_.bounds.x, control_.bounds.y); + // 所有控件均可选中,便于主窗口显示对应属性 setFlag(ItemIsSelectable, true); + // 开启可选中 + setFlag(ItemSendsGeometryChanges, true); + // 画布只处理左键交互,保留其他按键给视图默认行为 setAcceptedMouseButtons(Qt::LeftButton); } + // 返回图元自身坐标系中的矩形范围,用于绘制和命中测试 QRectF boundingRect() const override { return {0, @@ -53,6 +61,7 @@ public: const QStyleOptionGraphicsItem *option, QWidget *) override { + // 留出一个像素边距,避免描边被图元边界裁剪 const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); @@ -61,6 +70,7 @@ public: { case HmiControlType::Button: { + // 按钮显示文字,运行态点击行为由鼠标事件处理 painter->setBrush(QColor(QStringLiteral("#dcece3"))); painter->drawRoundedRect(rect, 4, 4); painter->setPen(QColor(QStringLiteral("#205c3b"))); @@ -69,6 +79,7 @@ public: } case HmiControlType::Indicator: { + // 指示灯颜色由最近一次读取到的 M 位值决定 const qreal diameter = std::min(rect.width(), rect.height() - 18.0); const QRectF lamp( rect.center().x() - diameter / 2.0, @@ -88,6 +99,7 @@ public: } case HmiControlType::NumericDisplay: { + // 数值显示为只读样式,文本由运行值刷新 painter->setBrush(QColor(QStringLiteral("#edf2f6"))); painter->drawRect(rect); painter->setPen(QColor(QStringLiteral("#24313b"))); @@ -98,6 +110,7 @@ public: } case HmiControlType::NumericInput: { + // 数值输入以白色编辑框样式呈现,双击后才请求写入 painter->setBrush(QColor(QStringLiteral("#ffffff"))); painter->drawRoundedRect(rect, 3, 3); painter->setPen(QColor(QStringLiteral("#24313b"))); @@ -109,6 +122,7 @@ public: case HmiControlType::Label: default: { + // 标签只显示固定文本,不绑定寄存器运行值 painter->setPen(QColor(QStringLiteral("#24313b"))); painter->drawText(rect, Qt::AlignCenter, QString::fromUtf8(control_.text.data(), @@ -119,22 +133,26 @@ public: if ((option->state & QStyle::State_Selected) != 0) { + // 选中框独立于控件类型,提示当前可编辑对象 painter->setBrush(Qt::NoBrush); painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); painter->drawRect(boundingRect().adjusted(0, 0, -1, -1)); } } + // 向场景和外部控件返回该图元对应的领域控件标识 const std::string &controlId() const { return control_.id; } + // 编辑态允许拖动,运行态保留点击或双击交互 void setInteractionEnabled(bool editable) { setFlag(ItemIsMovable, editable); } + // 缓存运行服务读出的值并触发 Qt 重绘 void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) { bit_value_ = bit_value; @@ -144,10 +162,13 @@ public: } protected: + // 拖拽过程中将新位置限制在页面可见边界内 QVariant itemChange(GraphicsItemChange change, const QVariant &value) override { + // 只有开启 ItemIsMovable 拖拽时,才做坐标钳位 if (change == ItemPositionChange && flags().testFlag(ItemIsMovable)) { + // 在图元层预先截断拖拽坐标,避免控件视觉上越出页面 QPointF position = value.toPointF(); const qreal maximum_x = std::max( 0.0, static_cast(page_width_ - control_.bounds.width)); @@ -160,24 +181,30 @@ protected: return QGraphicsItem::itemChange(change, value); } + // 运行态点击按钮时通知外层执行 M 位切换 void mousePressEvent(QGraphicsSceneMouseEvent *event) override { + // 条件:!ItemIsMovable 【也就是运行模式】 + 是按钮 + 有回调 if (!flags().testFlag(ItemIsMovable) && control_.type == HmiControlType::Button && activated_) { + // 运行态单击按钮才触发 M 位切换,编辑态只用于选择和拖动 setSelected(true); activated_(control_.id); event->accept(); return; } + // 编辑模式:不进if分支,执行基类事件——只做选中、拖拽 QGraphicsItem::mousePressEvent(event); } + // 运行态双击数值输入时通知外层弹出数值编辑对话框 void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override { if (!flags().testFlag(ItemIsMovable) && control_.type == HmiControlType::NumericInput && activated_) { + // 数值输入使用双击,避免普通选择操作意外写入 D 字 setSelected(true); activated_(control_.id); event->accept(); @@ -186,9 +213,11 @@ protected: QGraphicsItem::mouseDoubleClickEvent(event); } + // 拖拽结束后才提交最终坐标,避免移动过程频繁修改领域模型 void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override { QGraphicsItem::mouseReleaseEvent(event); + // 只有ItemIsMovable打开(编辑态),松开鼠标才提交位置给业务层 if (flags().testFlag(ItemIsMovable) && moved_) { moved_(control_.id, pos()); @@ -196,6 +225,7 @@ protected: } private: + // 根据控件类型和运行数据组合当前应绘制的文字 QString textWithValue() const { const QString text = QString::fromUtf8( @@ -212,16 +242,25 @@ private: return text; } + // 图元创建时的领域控件快照,提供类型、尺寸、文本和标识 HmiControl control_; + // 当前页面宽度,用于限制图元横向拖拽范围 int page_width_ = 0; + // 当前页面高度,用于限制图元纵向拖拽范围 int page_height_ = 0; + // 拖拽完成后回调 HmiEditorWidget 提交控件新位置 std::function moved_; + // 运行模式点击按钮 / 双击输入框回调,通知外层做寄存器读写 std::function activated_; + // 指示灯读取到的 M 位值 bool bit_value_ = false; + // 数值控件读取到的 D 字值 std::int16_t word_value_ = 0; + // 标记当前缓存值是否来自一次成功的运行时读取 bool has_runtime_value_ = false; }; +// 将场景通用图元安全转换为本文件定义的 HMI 控件图元 HmiGraphicsItem *asHmiItem(QGraphicsItem *item) { return dynamic_cast(item); @@ -247,6 +286,7 @@ HmiEditorWidget::HmiEditorWidget( this, &HmiEditorWidget::handleSelectionChanged); } +// 切换显示页面 void HmiEditorWidget::setPageId(const std::string &page_id) { if (page_id_ == page_id) @@ -280,8 +320,10 @@ void HmiEditorWidget::setRuntimeActive(bool active) } } +// 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 void HmiEditorWidget::reloadPage() { + // 画布始终从当前领域页面重建,避免保留已删除控件的图元 scene_->clear(); const HmiPage *page = editor_service_.findPage(page_id_); if (page == nullptr) @@ -318,6 +360,7 @@ void HmiEditorWidget::reloadPage() refreshRuntimeValues(); } +// 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 void HmiEditorWidget::selectControl(const std::string &control_id) { for (QGraphicsItem *item : scene_->items()) @@ -365,6 +408,7 @@ void HmiEditorWidget::refreshRuntimeValues() { continue; } + // 运行值通过服务读取,图元不直接接触寄存器仓库 const HmiRuntimeReadResult value = runtime_service_.readControl(*control); control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); } @@ -389,6 +433,7 @@ void HmiEditorWidget::fitCurrentPage() void HmiEditorWidget::updateItemInteractions() { + // 遍历所有控件图元,统一设置flag for (QGraphicsItem *item : scene_->items()) { HmiGraphicsItem *control_item = asHmiItem(item); @@ -412,6 +457,7 @@ void HmiEditorWidget::handleControlMoved( { return; } + // 鼠标坐标取整后再交给服务校验并写回模型 HmiRect bounds = control->bounds; bounds.x = static_cast(std::lround(position.x())); bounds.y = static_cast(std::lround(position.y())); @@ -438,6 +484,7 @@ void HmiEditorWidget::handleControlActivated(const std::string &control_id) return; } HmiRuntimeWriteResult result; + // 只有可写控件允许激活,其余控件只展示最新运行值 if (control->type == HmiControlType::Button) { result = runtime_service_.toggleButton(*control); diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h index 034ee57..e028e37 100644 --- a/app/src/ui/hmi_editor_widget.h +++ b/app/src/ui/hmi_editor_widget.h @@ -92,16 +92,27 @@ protected: void resizeEvent(QResizeEvent *event) override; private: + // 按页面尺寸缩放视图,保证完整画布保持可见 void fitCurrentPage(); + // 将当前编辑开关同步到所有控件图元 void updateItemInteractions(); + // 将场景选择转换为控件标识并通知属性面板 void handleSelectionChanged(); + // 将图元拖动后的坐标提交给 HMI 编辑服务 void handleControlMoved(const std::string &control_id, const QPointF &position); + // 在运行态处理按钮点击或数值输入激活 void handleControlActivated(const std::string &control_id); + // 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 HmiEditorService &editor_service_; + // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 HmiRuntimeService &runtime_service_; + // 持有所有页面图元和页面边框 QGraphicsScene *scene_ = nullptr; + // 当前画布投影的页面标识 std::string page_id_; + // 控制是否允许拖动和编辑控件 bool editing_enabled_ = true; + // 控制是否刷新运行值并响应运行态控件操作 bool runtime_active_ = false; }; diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 9e5f109..9e6e01f 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -134,10 +134,10 @@ MainWindow::MainWindow( hmi_runtime_service_(hmi_runtime_service) { ui_->setupUi(this); - configureAppearance(); - configureActions(); - configurePropertyEditor(); - configureHmiEditor(); + configureAppearance(); // 窗口样式、布局、状态栏标签初始化,纯视觉代码,无业务逻辑 + configureActions(); // 创建所有菜单 Action、工具栏按钮,绑定点击槽函数 + configurePropertyEditor(); // 动态构建属性表单:ID、文本、坐标宽高、寄存器区域 (M/D)、地址索引 + 应用按钮 + configureHmiEditor(); // 创建画布控件HmiEditorWidget嵌入主窗口,绑定画布信号:选中控件、控件拖动修改、编辑器报错;同时启动运行刷新定时器 hmi_editor_service_.ensureDefaultPage(); refreshProjectUi(); updateModeUi(tr("系统已进入编辑态")); @@ -290,12 +290,14 @@ void MainWindow::configureHmiEditor() connect(hmi_editor_widget_, &HmiEditorWidget::editorError, this, [this](const QString &message) { statusBar()->showMessage(message, 5000); }); + // 运行态定时投影仓库最新值,编辑态不触发寄存器读取 runtime_refresh_timer_ = new QTimer(this); runtime_refresh_timer_->setInterval(150); connect(runtime_refresh_timer_, &QTimer::timeout, this, [this] { + // 离线 / 联机运行模式 if (runtime_mode_service_.mode() != ApplicationMode::Editing) { hmi_editor_widget_->refreshRuntimeValues(); @@ -352,6 +354,7 @@ void MainWindow::configurePropertyEditor() void MainWindow::refreshProjectUi() { + // 工程树只展示模型摘要,画布内容由 HmiEditorWidget 单独投影 const std::string current_page_id = hmi_editor_service_.firstPageId(); if (hmi_editor_widget_ != nullptr) { @@ -376,14 +379,23 @@ void MainWindow::refreshProjectUi() ui_->projectTree->expandAll(); } +// 根据控件ID加载控件属性到右侧属性面板 void MainWindow::showControlProperties(const std::string &control_id) { + // 更新窗口全局状态:记录当前选中控件ID selected_control_id_ = control_id; + + // 在第一页画面中,根据ID查找目标控件,获取只读模型指针 const HmiControl *control = hmi_editor_service_.findControl( hmi_editor_service_.firstPageId(), control_id); + // 判断是否查询到有效控件 const bool has_control = control != nullptr; + + // 更新属性面板顶部状态标签,显示选中控件ID或提示未选择 ui_->selectionValueLabel->setText( has_control ? fromUtf8(control->id) : tr("未选择")); + + // 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑 for (QWidget *widget : {static_cast(control_id_edit_), static_cast(control_text_edit_), static_cast(control_x_spin_box_), @@ -396,21 +408,33 @@ void MainWindow::showControlProperties(const std::string &control_id) { widget->setEnabled(has_control); } + + // 未选中有效控件,无需填充属性,直接退出 if (!has_control) { return; } + + // 将控件基础信息填充至UI输入框 control_id_edit_->setText(fromUtf8(control->id)); control_text_edit_->setText(fromUtf8(control->text)); + // 控件位置坐标 control_x_spin_box_->setValue(control->bounds.x); control_y_spin_box_->setValue(control->bounds.y); + // 控件宽高尺寸 control_width_spin_box_->setValue(control->bounds.width); control_height_spin_box_->setValue(control->bounds.height); + + // 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区 const RegisterArea default_area = usesMAddress(control->type) ? RegisterArea::M : RegisterArea::D; + // 优先使用控件已绑定的寄存器区域;无绑定则使用类型对应的默认区域 const RegisterArea area = control->binding.has_value() ? control->binding->area() : default_area; + + // 同步寄存器区域下拉框选中项(0=M,1=D,与combo初始化顺序严格对应) binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); + // 填充寄存器地址索引;无绑定时默认地址为0 binding_index_spin_box_->setValue( control->binding.has_value() ? control->binding->index() : 0); } @@ -458,6 +482,7 @@ void MainWindow::applySelectedControlProperties() { return; } + // 从现有控件复制不可见属性,避免属性面板覆盖扩展配置 HmiControl control = *old_control; control.id = toUtf8(control_id_edit_->text()); control.text = toUtf8(control_text_edit_->text()); @@ -566,6 +591,7 @@ void MainWindow::showProjectResult( void MainWindow::requestMode(ApplicationMode requested_mode) { + // UI 仅转发模式意图,合法性由服务层和领域状态机决定 ModeTransitionResult result; switch (requested_mode) { @@ -605,6 +631,7 @@ void MainWindow::updateModeUi(const QString &message) const ApplicationMode mode = runtime_mode_service_.mode(); const ModePolicy policy = runtime_mode_service_.policy(); restoreCurrentModeAction(); + // 将同一份模式策略同步到所有可编辑入口,避免只禁用部分操作 ui_->projectDock->setEnabled(policy.allowsProjectEditing); ui_->propertiesDock->setEnabled(policy.allowsProjectEditing); hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); @@ -646,6 +673,7 @@ void MainWindow::updateModeUi(const QString &message) ui_->outputList->scrollToBottom(); } +// 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态 void MainWindow::restoreCurrentModeAction() { ui_->editingModeAction->setChecked(runtime_mode_service_.mode() diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 440d2d8..a356fc7 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -65,19 +65,33 @@ public: ~MainWindow() override; private: + // 配置菜单和工具栏动作 void configureActions(); + // 配置主窗口外观和状态栏 void configureAppearance(); + // 创建并连接 HMI 编辑画布 void configureHmiEditor(); + // 创建并连接控件属性编辑表单 void configurePropertyEditor(); + // 刷新工程树和当前 HMI 页面信息 void refreshProjectUi(); + // 显示指定 HMI 控件的可编辑属性 void showControlProperties(const std::string &control_id); + // 向当前页面添加指定类型的 HMI 控件 void addHmiControl(HmiControlType type); + // 删除当前选中的 HMI 控件 void deleteSelectedControl(); + // 将属性表单内容应用到当前选中控件 void applySelectedControlProperties(); + // 创建新的工程并刷新编辑界面 void createNewProject(); + // 保存当前工程到已关联的路径 void saveProject(); + // 将当前工程保存到用户指定的路径 void saveProjectAs(); + // 从用户指定的路径加载工程 void loadProject(); + // 在状态栏和输出面板显示工程操作结果 void showProjectResult(const QString &action, const QString &message, bool succeeded); /** diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index 19c5046..0dd8e10 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -23,6 +23,7 @@ void require(bool condition, const std::string &message) void testRegisterAddressBoundaries() { + // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 require(RegisterAddress{RegisterArea::M, 0}.isValid(), "M0 must be valid"); require(RegisterAddress{RegisterArea::D, 4000}.isValid(), @@ -37,6 +38,7 @@ void testRegisterAddressBoundaries() void testRegisterRepositorySeparatesAreas() { + // 验证离线仓库不会把 M 位和 D 字交叉解释 VirtualRegisterRepository repository; const RegisterAddress m0{RegisterArea::M, 0}; const RegisterAddress d0{RegisterArea::D, 0}; @@ -54,6 +56,7 @@ void testRegisterRepositorySeparatesAreas() Project makeValidProject() { + // 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线 HmiControl start_button; start_button.id = "start-button"; start_button.type = HmiControlType::Button; @@ -92,6 +95,7 @@ Project makeValidProject() void testLogicNodeConfigurationBoundaries() { + // 触点只能绑定 M 区,数值比较只能绑定 D 区 LogicNode contact; contact.id = "contact"; contact.config = ContactNodeConfig{ @@ -115,6 +119,7 @@ void testLogicNodeConfigurationBoundaries() void testModelsValidateBindingsAndIdentifiers() { + // 聚合验证必须拒绝错误绑定、重复标识和越界控件 Project project = makeValidProject(); require(project.validate(), "valid project model must pass validation"); @@ -141,6 +146,7 @@ void testModelsValidateBindingsAndIdentifiers() void testRuntimeStateBoundaries() { + // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件 RuntimeState state; require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); @@ -167,6 +173,7 @@ int main() { try { + // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程 testRegisterAddressBoundaries(); testRegisterRepositorySeparatesAreas(); testLogicNodeConfigurationBoundaries(); diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index 849baa7..ea971b4 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -14,6 +14,7 @@ namespace { class TestProjectStorage final : public ProjectStorage { public: + // 编辑服务测试只关注内存模型变更,不依赖真实文件系统 ProjectSaveResult save(const Project &, const std::string &) override { return {true, ProjectStorageError::None, {}}; @@ -35,6 +36,7 @@ void require(bool condition, const std::string &message) void testControlEditing() { + // 覆盖控件创建、移动、绑定校验、重命名冲突和删除流程 TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService service(project_service); @@ -81,6 +83,7 @@ void testControlEditing() void testRuntimeUsesRegisterRepository() { + // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); @@ -123,6 +126,7 @@ int main() { try { + // 编辑和运行场景分别验证服务层两条独立职责 testControlEditing(); testRuntimeUsesRegisterRepository(); } diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index 96c032f..c1d34cc 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -22,6 +22,7 @@ namespace { class TestProjectStorage final : public ProjectStorage { public: + // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响 ProjectSaveResult save(const Project &, const std::string &) override { return {true, ProjectStorageError::None, {}}; @@ -44,6 +45,7 @@ void require(bool condition, const std::string &message) template ObjectType *requiredChild(MainWindow &window, const char *name) { + // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息 ObjectType *child = window.findChild(QString::fromLatin1(name)); require(child != nullptr, std::string("missing UI object ") + name); return child; @@ -51,6 +53,7 @@ ObjectType *requiredChild(MainWindow &window, const char *name) void testModeActionsControlEditingAvailability() { + // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择 TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); @@ -134,6 +137,7 @@ void testModeActionsControlEditingAvailability() int main(int argc, char *argv[]) { + // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行 qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); QApplication application(argc, argv); diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 1ac1d62..281f607 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -22,6 +22,7 @@ void require(bool condition, const std::string &message) Project makeExampleProject() { + // 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本 HmiControl start_button; start_button.id = "start-button"; start_button.type = HmiControlType::Button; @@ -98,6 +99,7 @@ Project makeExampleProject() void writeText(const QString &path, const QByteArray &content) { + // 直接写入故障样本文件,以验证加载失败时的保护行为 QFile file(path); require(file.open(QIODevice::WriteOnly), "test file must be writable"); require(file.write(content) == content.size(), "test file must be written completely"); @@ -112,6 +114,7 @@ QByteArray readBytes(const QString &path) void testEmptyProjectRoundTrip() { + // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); @@ -136,6 +139,7 @@ void testEmptyProjectRoundTrip() void testExampleProjectRoundTrip() { + // 验证各层嵌套字段往返后保持不变且序列化结果稳定 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); @@ -186,6 +190,7 @@ void testExampleProjectRoundTrip() void testInvalidFiles() { + // 非法文件必须被拒绝,并且不得覆盖服务中当前工程 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); @@ -219,6 +224,7 @@ void testInvalidFiles() void testServiceStateAndSaveErrors() { + // 保存路径和修改标记只在成功持久化后更新 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); @@ -252,6 +258,7 @@ int main() { try { + // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 testEmptyProjectRoundTrip(); testExampleProjectRoundTrip(); testInvalidFiles(); diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 3846beb..f8bd456 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -16,6 +16,7 @@ void require(bool condition, const std::string &message) void testModeTransitions() { + // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 RuntimeModeService service; require(service.mode() == ApplicationMode::Editing, @@ -54,6 +55,7 @@ int main() { try { + // 运行模式只有这一组状态机边界测试 testModeTransitions(); } catch (const std::exception &error)