| @@ -24,7 +24,11 @@ SOURCES += \ | |||
| src/domain/project_model.cpp \ | |||
| src/domain/runtime_state.cpp \ | |||
| src/services/project_service.cpp \ | |||
| src/infrastructure/json_project_storage.cpp | |||
| src/services/hmi_editor_service.cpp \ | |||
| src/services/hmi_runtime_service.cpp \ | |||
| src/services/runtime_mode_service.cpp \ | |||
| src/infrastructure/json_project_storage.cpp \ | |||
| src/ui/hmi_editor_widget.cpp | |||
| HEADERS += \ | |||
| src/ui/main_window.h \ | |||
| @@ -36,7 +40,11 @@ HEADERS += \ | |||
| src/domain/runtime_state.h \ | |||
| src/domain/project_storage.h \ | |||
| src/services/project_service.h \ | |||
| src/infrastructure/json_project_storage.h | |||
| src/services/hmi_editor_service.h \ | |||
| src/services/hmi_runtime_service.h \ | |||
| src/services/runtime_mode_service.h \ | |||
| src/infrastructure/json_project_storage.h \ | |||
| src/ui/hmi_editor_widget.h | |||
| FORMS += \ | |||
| src/ui/main_window.ui | |||
| @@ -2,8 +2,6 @@ | |||
| #include <algorithm> | |||
| namespace integrated_platform::domain { | |||
| namespace { | |||
| // 在提供错误字符串时写入校验失败原因 | |||
| @@ -159,5 +157,3 @@ bool ControlLogic::validate(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| } // namespace integrated_platform::domain | |||
| @@ -7,8 +7,6 @@ | |||
| #include <variant> | |||
| #include <vector> | |||
| namespace integrated_platform::domain { | |||
| // 触点工作方式 | |||
| enum class ContactMode | |||
| { | |||
| @@ -66,7 +64,7 @@ struct CompareNodeConfig | |||
| // 控制逻辑节点支持的配置类型 | |||
| using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | |||
| // 描述控制逻辑中的一个功能节点 | |||
| struct LogicNode | |||
| { | |||
| @@ -108,5 +106,3 @@ struct ControlLogic | |||
| // 校验控制逻辑并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -2,8 +2,6 @@ | |||
| #include <algorithm> | |||
| namespace integrated_platform::domain { | |||
| namespace { | |||
| // 在提供错误字符串时写入校验失败原因 | |||
| @@ -41,6 +39,14 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI control bounds must have positive size"); | |||
| return false; | |||
| } | |||
| for (const auto &property : properties) | |||
| { | |||
| if (property.first.empty()) | |||
| { | |||
| setError(error, "HMI control property names must not be empty"); | |||
| return false; | |||
| } | |||
| } | |||
| if (requiresBitBinding(type)) | |||
| { | |||
| if (!binding.has_value() || binding->area() != RegisterArea::M) | |||
| @@ -86,6 +92,13 @@ bool HmiPage::validate(std::string *error) const | |||
| { | |||
| return false; | |||
| } | |||
| if (control.bounds.x < 0 || control.bounds.y < 0 | |||
| || control.bounds.x > width - control.bounds.width | |||
| || control.bounds.y > height - control.bounds.height) | |||
| { | |||
| setError(error, "HMI control bounds must stay within the page"); | |||
| return false; | |||
| } | |||
| // 检查控件 id 是否唯一 | |||
| if (std::find(ids.cbegin(), ids.cend(), control.id) != ids.cend()) | |||
| { | |||
| @@ -96,5 +109,3 @@ bool HmiPage::validate(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| } // namespace integrated_platform::domain | |||
| @@ -1,3 +1,10 @@ | |||
| /** | |||
| * @file hmi_model.h | |||
| * @brief 定义可保存的 HMI 页面、控件和寄存器绑定模型 | |||
| * @author suyu | |||
| * @date 2026-08-10 | |||
| */ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| @@ -7,9 +14,11 @@ | |||
| #include <string> | |||
| #include <vector> | |||
| namespace integrated_platform::domain { | |||
| // 描述 HMI 控件在页面中的位置和尺寸 | |||
| /** | |||
| * @brief 描述 HMI 控件在页面坐标系中的位置和尺寸 | |||
| * | |||
| * 页面校验要求矩形的左上角非负且完整位于页面范围内 | |||
| */ | |||
| struct HmiRect | |||
| { | |||
| static constexpr int kDefaultWidth = 80; | |||
| @@ -17,35 +26,53 @@ struct HmiRect | |||
| int x = 0; | |||
| int y = 0; | |||
| int width = kDefaultWidth; | |||
| int width = kDefaultWidth; | |||
| int height = kDefaultHeight; | |||
| }; | |||
| // HMI 控件类型 | |||
| /** | |||
| * @brief HMI 控件的基础类型 | |||
| * | |||
| * 按钮和指示灯只能绑定 M 位,数值控件只能绑定 D 字 | |||
| */ | |||
| enum class HmiControlType | |||
| { | |||
| Button, // 按钮控件 | |||
| Indicator, // 指示灯控件 | |||
| NumericDisplay, // 数值显示控件 | |||
| NumericInput, // 数值输入控件 | |||
| Label // 文本标签控件 | |||
| Button, | |||
| Indicator, | |||
| NumericDisplay, | |||
| NumericInput, | |||
| Label | |||
| }; | |||
| // 描述一个 HMI 控件及其显示属性和寄存器绑定关系 | |||
| /** | |||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | |||
| * | |||
| * `properties` 保存未纳入公共字段的字符串扩展属性 | |||
| */ | |||
| struct HmiControl | |||
| { | |||
| std::string id; | |||
| HmiControlType type = HmiControlType::Label; | |||
| HmiRect bounds; | |||
| std::string text; | |||
| std::optional<RegisterAddress> binding; // 控件绑定的寄存器地址,可以没有绑定;例如按钮绑定 M100 | |||
| std::map<std::string, std::string> properties; // 保存控件的扩展属性,例如颜色、字体、最小值等 | |||
| std::optional<RegisterAddress> binding; | |||
| std::map<std::string, std::string> properties; | |||
| // 校验控件配置并通过 error 返回失败原因 | |||
| /** | |||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | |||
| * @param error 可选错误输出,失败时写入首个校验原因 | |||
| * @return 配置完整且满足控件类型绑定规则时返回 true | |||
| * | |||
| * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 | |||
| */ | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 描述一个 HMI 页面及其包含的控件集合 | |||
| /** | |||
| * @brief 描述一个 HMI 页面及其控件集合 | |||
| * | |||
| * 控件标识在同一页面内必须唯一,所有控件必须完整位于页面边界内 | |||
| */ | |||
| struct HmiPage | |||
| { | |||
| std::string id; | |||
| @@ -54,8 +81,10 @@ struct HmiPage | |||
| int height = 480; | |||
| std::vector<HmiControl> controls; | |||
| // 校验页面配置和控件集合并通过 error 返回失败原因 | |||
| /** | |||
| * @brief 校验页面尺寸、控件标识唯一性和全部控件配置 | |||
| * @param error 可选错误输出,失败时写入首个校验原因 | |||
| * @return 页面及其全部控件有效时返回 true | |||
| */ | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -2,8 +2,6 @@ | |||
| #include <algorithm> | |||
| namespace integrated_platform::domain { | |||
| namespace { | |||
| void setError(std::string *error, const std::string &message) | |||
| @@ -14,6 +12,12 @@ void setError(std::string *error, const std::string &message) | |||
| } | |||
| } | |||
| /** | |||
| * @brief 检查对象集合中是否存在重复 id | |||
| * @tparam TItem 包含 id 成员的对象类型 | |||
| * @param items 需要检查的对象集合 | |||
| * @return 存在重复 id 时返回 true 否则返回 false | |||
| */ | |||
| template <typename TItem> | |||
| bool containsDuplicateId(const std::vector<TItem> &items) | |||
| { | |||
| @@ -22,7 +26,10 @@ bool containsDuplicateId(const std::vector<TItem> &items) | |||
| const auto duplicate = std::find_if( | |||
| current + 1, | |||
| items.cend(), | |||
| [¤t](const TItem &item) { return item.id == current->id; }); | |||
| [¤t](const TItem &item) | |||
| { | |||
| return item.id == current->id; | |||
| }); | |||
| if (duplicate != items.cend()) | |||
| { | |||
| return true; | |||
| @@ -66,5 +73,3 @@ bool Project::validate(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| } // namespace integrated_platform::domain | |||
| @@ -6,8 +6,6 @@ | |||
| #include <string> | |||
| #include <vector> | |||
| namespace integrated_platform::domain { | |||
| // 描述工程的基本身份和文件格式信息 | |||
| struct ProjectMetadata | |||
| { | |||
| @@ -32,5 +30,3 @@ struct Project | |||
| // 校验工程配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -4,8 +4,6 @@ | |||
| #include <string> | |||
| namespace integrated_platform::domain { | |||
| // 工程文件存储失败原因 | |||
| enum class ProjectStorageError | |||
| { | |||
| @@ -59,5 +57,3 @@ public: | |||
| */ | |||
| virtual ProjectLoadResult load(const std::string &file_path) = 0; | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -1,7 +1,5 @@ | |||
| #include "register_address.h" | |||
| namespace integrated_platform::domain { | |||
| namespace { | |||
| bool isSupportedArea(RegisterArea area) | |||
| @@ -60,5 +58,3 @@ bool RegisterAddress::operator!=(const RegisterAddress &other) const | |||
| { | |||
| return !(*this == other); | |||
| } | |||
| } // namespace integrated_platform::domain | |||
| @@ -2,8 +2,6 @@ | |||
| #include <string> | |||
| namespace integrated_platform::domain { | |||
| enum class RegisterArea | |||
| { | |||
| M, | |||
| @@ -30,5 +28,3 @@ private: | |||
| RegisterArea area_; | |||
| int index_; | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -1,14 +1,14 @@ | |||
| #include "register_repository.h" | |||
| namespace integrated_platform::domain { | |||
| VirtualRegisterRepository::VirtualRegisterRepository() | |||
| { | |||
| // 构造时清空离线寄存器,确保初始值确定 | |||
| clear(); | |||
| } | |||
| BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) const | |||
| { | |||
| // 先校验地址和区域,再访问对应的内存槽位 | |||
| if (!address.isValid()) | |||
| { | |||
| return {false, false, RegisterError::InvalidAddress}; | |||
| @@ -23,6 +23,7 @@ BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) | |||
| RegisterWriteResult VirtualRegisterRepository::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| // 位操作只允许访问 M 区 | |||
| if (!address.isValid()) | |||
| { | |||
| return {false, RegisterError::InvalidAddress}; | |||
| @@ -37,6 +38,7 @@ RegisterWriteResult VirtualRegisterRepository::writeBit( | |||
| WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &address) const | |||
| { | |||
| // 字操作只允许访问 D 区 | |||
| if (!address.isValid()) | |||
| { | |||
| return {false, 0, RegisterError::InvalidAddress}; | |||
| @@ -51,6 +53,7 @@ WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &addres | |||
| RegisterWriteResult VirtualRegisterRepository::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| // 校验通过后更新 D 区对应的内存槽位 | |||
| if (!address.isValid()) | |||
| { | |||
| return {false, RegisterError::InvalidAddress}; | |||
| @@ -65,8 +68,7 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( | |||
| void VirtualRegisterRepository::clear() | |||
| { | |||
| // 离线运行复位时同时清空 M 区和 D 区 | |||
| bits_.fill(false); | |||
| words_.fill(0); | |||
| } | |||
| } // namespace integrated_platform::domain | |||
| @@ -6,8 +6,7 @@ | |||
| #include <cstddef> | |||
| #include <cstdint> | |||
| namespace integrated_platform::domain { | |||
| // 寄存器仓库操作结果 | |||
| enum class RegisterError | |||
| { | |||
| None, // 操作成功 无错误 | |||
| @@ -17,56 +16,68 @@ enum class RegisterError | |||
| WriteRejected // 仓库拒绝写入请求 | |||
| }; | |||
| // M 区位寄存器读取结果 | |||
| struct BitReadResult | |||
| { | |||
| bool succeeded = false; | |||
| bool value = false; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| bool succeeded = false; // 读取是否成功 | |||
| bool value = false; // 读取到的位值 | |||
| RegisterError error = RegisterError::Unavailable; // 读取失败原因 | |||
| }; | |||
| // D 区字寄存器读取结果 | |||
| struct WordReadResult | |||
| { | |||
| bool succeeded = false; | |||
| std::int16_t value = 0; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| bool succeeded = false; // 读取是否成功 | |||
| std::int16_t value = 0; // 读取到的字值 | |||
| RegisterError error = RegisterError::Unavailable; // 读取失败原因 | |||
| }; | |||
| // 寄存器写入结果 | |||
| struct RegisterWriteResult | |||
| { | |||
| bool succeeded = false; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| bool succeeded = false; // 写入是否成功 | |||
| RegisterError error = RegisterError::Unavailable; // 写入失败原因 | |||
| }; | |||
| // M/D 寄存器访问边界 | |||
| class RegisterRepository | |||
| { | |||
| public: | |||
| virtual ~RegisterRepository() = default; | |||
| // 读取 M 区位寄存器 | |||
| virtual BitReadResult readBit(const RegisterAddress &address) const = 0; | |||
| // 写入 M 区位寄存器 | |||
| virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0; | |||
| // 读取 D 区字寄存器 | |||
| virtual WordReadResult readWord(const RegisterAddress &address) const = 0; | |||
| // 写入 D 区字寄存器 | |||
| virtual RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) = 0; | |||
| }; | |||
| // 基于内存的离线 M/D 寄存器仓库 | |||
| class VirtualRegisterRepository : public RegisterRepository | |||
| { | |||
| public: | |||
| VirtualRegisterRepository(); | |||
| // 读取内存中的 M 区位值 | |||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||
| // 更新内存中的 M 区位值 | |||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||
| // 读取内存中的 D 区字值 | |||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||
| // 更新内存中的 D 区字值 | |||
| RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) override; | |||
| // 将所有虚拟寄存器恢复为默认值 | |||
| void clear(); | |||
| private: | |||
| static constexpr std::size_t kRegisterCount = | |||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||
| std::array<bool, kRegisterCount> bits_{}; | |||
| std::array<std::int16_t, kRegisterCount> words_{}; | |||
| std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存 | |||
| std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存 | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -1,7 +1,5 @@ | |||
| #include "runtime_state.h" | |||
| namespace integrated_platform::domain { | |||
| ApplicationMode RuntimeState::mode() const | |||
| { | |||
| return mode_; | |||
| @@ -53,5 +51,3 @@ ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_comp | |||
| mode_ = ApplicationMode::OnlineRunning; | |||
| return {true, ModeTransitionError::None}; | |||
| } | |||
| } // namespace integrated_platform::domain | |||
| @@ -1,7 +1,5 @@ | |||
| #pragma once | |||
| namespace integrated_platform::domain { | |||
| // 应用当前运行模式 | |||
| enum class ApplicationMode | |||
| { | |||
| @@ -91,5 +89,3 @@ private: | |||
| // 当前运行模式 初始为编辑态 | |||
| ApplicationMode mode_ = ApplicationMode::Editing; | |||
| }; | |||
| } // namespace integrated_platform::domain | |||
| @@ -12,8 +12,6 @@ | |||
| #include <string> | |||
| #include <utility> | |||
| namespace integrated_platform::infrastructure { | |||
| namespace { | |||
| // 当前读写实现支持的工程文件格式版本 | |||
| @@ -22,13 +20,13 @@ constexpr const char *kCurrentFormatVersion = "1.0"; | |||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | |||
| struct ParseState | |||
| { | |||
| domain::ProjectStorageError error = domain::ProjectStorageError::None; | |||
| ProjectStorageError error = ProjectStorageError::None; | |||
| std::string message; | |||
| // 记录首次解析错误并返回 false,便于解析函数直接向上传播失败 | |||
| bool fail(domain::ProjectStorageError new_error, const std::string &new_message) | |||
| bool fail(ProjectStorageError new_error, const std::string &new_message) | |||
| { | |||
| if (error == domain::ProjectStorageError::None) | |||
| if (error == ProjectStorageError::None) | |||
| { | |||
| error = new_error; | |||
| message = new_message; | |||
| @@ -68,7 +66,7 @@ bool readValue( | |||
| if (!object.contains(key)) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::MissingField, | |||
| ProjectStorageError::MissingField, | |||
| "missing required field " + fieldPath(context, field)); | |||
| } | |||
| *value = object.value(key); | |||
| @@ -91,7 +89,7 @@ bool readString( | |||
| if (!json_value.isString()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " must be a string"); | |||
| } | |||
| *value = toUtf8(json_value.toString()); | |||
| @@ -114,7 +112,7 @@ bool readBool( | |||
| if (!json_value.isBool()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " must be a boolean"); | |||
| } | |||
| *value = json_value.toBool(); | |||
| @@ -139,7 +137,7 @@ bool readInt( | |||
| if (!json_value.isDouble()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " must be an integer"); | |||
| } | |||
| @@ -149,7 +147,7 @@ bool readInt( | |||
| || number < minimum || number > maximum) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " is outside the supported integer range"); | |||
| } | |||
| *value = static_cast<int>(number); | |||
| @@ -172,7 +170,7 @@ bool readObject( | |||
| if (!json_value.isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " must be an object"); | |||
| } | |||
| *value = json_value.toObject(); | |||
| @@ -195,7 +193,7 @@ bool readArray( | |||
| if (!json_value.isArray()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " must be an array"); | |||
| } | |||
| *value = json_value.toArray(); | |||
| @@ -203,11 +201,11 @@ bool readArray( | |||
| } | |||
| // 将寄存器地址序列化为包含区域和原始索引的 JSON 对象 | |||
| QJsonObject serializeAddress(const domain::RegisterAddress &address) | |||
| QJsonObject serializeAddress(const RegisterAddress &address) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("area"), | |||
| address.area() == domain::RegisterArea::M ? QStringLiteral("M") | |||
| address.area() == RegisterArea::M ? QStringLiteral("M") | |||
| : QStringLiteral("D")); | |||
| object.insert(QStringLiteral("index"), address.index()); | |||
| return object; | |||
| @@ -217,7 +215,7 @@ QJsonObject serializeAddress(const domain::RegisterAddress &address) | |||
| bool parseAddress( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::RegisterAddress *address, | |||
| RegisterAddress *address, | |||
| ParseState *state) | |||
| { | |||
| std::string area_text; | |||
| @@ -227,56 +225,56 @@ bool parseAddress( | |||
| object, | |||
| "index", | |||
| context, | |||
| domain::RegisterAddress::kMinimumIndex, | |||
| domain::RegisterAddress::kMaximumIndex, | |||
| RegisterAddress::kMinimumIndex, | |||
| RegisterAddress::kMaximumIndex, | |||
| &index, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| domain::RegisterArea area = domain::RegisterArea::M; | |||
| RegisterArea area = RegisterArea::M; | |||
| if (area_text == "M") | |||
| { | |||
| area = domain::RegisterArea::M; | |||
| area = RegisterArea::M; | |||
| } | |||
| else if (area_text == "D") | |||
| { | |||
| area = domain::RegisterArea::D; | |||
| area = RegisterArea::D; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| context + ".area must be M or D"); | |||
| } | |||
| *address = domain::RegisterAddress{area, index}; | |||
| *address = RegisterAddress{area, index}; | |||
| return true; | |||
| } | |||
| // 将 HMI 控件类型枚举转换为工程文件中的稳定字符串 | |||
| QString hmiControlTypeName(domain::HmiControlType type) | |||
| QString hmiControlTypeName(HmiControlType type) | |||
| { | |||
| switch (type) | |||
| { | |||
| case domain::HmiControlType::Button: | |||
| case HmiControlType::Button: | |||
| { | |||
| return QStringLiteral("button"); | |||
| } | |||
| case domain::HmiControlType::Indicator: | |||
| case HmiControlType::Indicator: | |||
| { | |||
| return QStringLiteral("indicator"); | |||
| } | |||
| case domain::HmiControlType::NumericDisplay: | |||
| case HmiControlType::NumericDisplay: | |||
| { | |||
| return QStringLiteral("numericDisplay"); | |||
| } | |||
| case domain::HmiControlType::NumericInput: | |||
| case HmiControlType::NumericInput: | |||
| { | |||
| return QStringLiteral("numericInput"); | |||
| } | |||
| case domain::HmiControlType::Label: | |||
| case HmiControlType::Label: | |||
| { | |||
| return QStringLiteral("label"); | |||
| } | |||
| @@ -289,39 +287,39 @@ QString hmiControlTypeName(domain::HmiControlType type) | |||
| // 将工程文件中的控件类型字符串转换为 HMI 控件类型枚举 | |||
| bool parseHmiControlType( | |||
| const std::string &value, domain::HmiControlType *type, ParseState *state) | |||
| const std::string &value, HmiControlType *type, ParseState *state) | |||
| { | |||
| if (value == "button") | |||
| { | |||
| *type = domain::HmiControlType::Button; | |||
| *type = HmiControlType::Button; | |||
| } | |||
| else if (value == "indicator") | |||
| { | |||
| *type = domain::HmiControlType::Indicator; | |||
| *type = HmiControlType::Indicator; | |||
| } | |||
| else if (value == "numericDisplay") | |||
| { | |||
| *type = domain::HmiControlType::NumericDisplay; | |||
| *type = HmiControlType::NumericDisplay; | |||
| } | |||
| else if (value == "numericInput") | |||
| { | |||
| *type = domain::HmiControlType::NumericInput; | |||
| *type = HmiControlType::NumericInput; | |||
| } | |||
| else if (value == "label") | |||
| { | |||
| *type = domain::HmiControlType::Label; | |||
| *type = HmiControlType::Label; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "unsupported HMI control type " + value); | |||
| } | |||
| return true; | |||
| } | |||
| // 将 HMI 控件矩形区域序列化为 JSON 对象 | |||
| QJsonObject serializeBounds(const domain::HmiRect &bounds) | |||
| QJsonObject serializeBounds(const HmiRect &bounds) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("x"), bounds.x); | |||
| @@ -335,7 +333,7 @@ QJsonObject serializeBounds(const domain::HmiRect &bounds) | |||
| bool parseBounds( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::HmiRect *bounds, | |||
| HmiRect *bounds, | |||
| ParseState *state) | |||
| { | |||
| return readInt( | |||
| @@ -394,7 +392,7 @@ bool parseProperties( | |||
| if (!current.value().isString()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "HMI control property values must be strings"); | |||
| } | |||
| properties->emplace(toUtf8(current.key()), toUtf8(current.value().toString())); | |||
| @@ -403,7 +401,7 @@ bool parseProperties( | |||
| } | |||
| // 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象 | |||
| QJsonObject serializeHmiControl(const domain::HmiControl &control) | |||
| QJsonObject serializeHmiControl(const HmiControl &control) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(control.id)); | |||
| @@ -427,7 +425,7 @@ QJsonObject serializeHmiControl(const domain::HmiControl &control) | |||
| bool parseHmiControl( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::HmiControl *control, | |||
| HmiControl *control, | |||
| ParseState *state) | |||
| { | |||
| std::string type_text; | |||
| @@ -459,11 +457,11 @@ bool parseHmiControl( | |||
| if (!binding.isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| context + ".binding must be an object or null"); | |||
| } | |||
| domain::RegisterAddress address{domain::RegisterArea::M, 0}; | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| if (!parseAddress(binding.toObject(), context + ".binding", &address, state)) | |||
| { | |||
| return false; | |||
| @@ -473,10 +471,10 @@ bool parseHmiControl( | |||
| } | |||
| // 将 HMI 页面及其全部控件序列化为 JSON 对象 | |||
| QJsonObject serializeHmiPage(const domain::HmiPage &page) | |||
| QJsonObject serializeHmiPage(const HmiPage &page) | |||
| { | |||
| QJsonArray controls; | |||
| for (const domain::HmiControl &control : page.controls) | |||
| for (const HmiControl &control : page.controls) | |||
| { | |||
| controls.append(serializeHmiControl(control)); | |||
| } | |||
| @@ -494,7 +492,7 @@ QJsonObject serializeHmiPage(const domain::HmiPage &page) | |||
| bool parseHmiPage( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::HmiPage *page, | |||
| HmiPage *page, | |||
| ParseState *state) | |||
| { | |||
| QJsonArray controls; | |||
| @@ -516,10 +514,10 @@ bool parseHmiPage( | |||
| if (!controls.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| context + ".controls items must be objects"); | |||
| } | |||
| domain::HmiControl control; | |||
| HmiControl control; | |||
| if (!parseHmiControl( | |||
| controls.at(index).toObject(), | |||
| context + ".controls[" + std::to_string(index) + ']', | |||
| @@ -534,48 +532,48 @@ bool parseHmiPage( | |||
| } | |||
| // 将触点模式枚举转换为工程文件中的稳定字符串 | |||
| QString contactModeName(domain::ContactMode mode) | |||
| QString contactModeName(ContactMode mode) | |||
| { | |||
| return mode == domain::ContactMode::NormallyOpen | |||
| return mode == ContactMode::NormallyOpen | |||
| ? QStringLiteral("normallyOpen") | |||
| : QStringLiteral("normallyClosed"); | |||
| } | |||
| // 将工程文件中的触点模式字符串转换为枚举 | |||
| bool parseContactMode( | |||
| const std::string &value, domain::ContactMode *mode, ParseState *state) | |||
| const std::string &value, ContactMode *mode, ParseState *state) | |||
| { | |||
| if (value == "normallyOpen") | |||
| { | |||
| *mode = domain::ContactMode::NormallyOpen; | |||
| *mode = ContactMode::NormallyOpen; | |||
| } | |||
| else if (value == "normallyClosed") | |||
| { | |||
| *mode = domain::ContactMode::NormallyClosed; | |||
| *mode = ContactMode::NormallyClosed; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "unsupported contact mode " + value); | |||
| } | |||
| return true; | |||
| } | |||
| // 将线圈模式枚举转换为工程文件中的稳定字符串 | |||
| QString coilModeName(domain::CoilMode mode) | |||
| QString coilModeName(CoilMode mode) | |||
| { | |||
| switch (mode) | |||
| { | |||
| case domain::CoilMode::Normal: | |||
| case CoilMode::Normal: | |||
| { | |||
| return QStringLiteral("normal"); | |||
| } | |||
| case domain::CoilMode::Set: | |||
| case CoilMode::Set: | |||
| { | |||
| return QStringLiteral("set"); | |||
| } | |||
| case domain::CoilMode::Reset: | |||
| case CoilMode::Reset: | |||
| { | |||
| return QStringLiteral("reset"); | |||
| } | |||
| @@ -587,55 +585,55 @@ QString coilModeName(domain::CoilMode mode) | |||
| } | |||
| // 将工程文件中的线圈模式字符串转换为枚举 | |||
| bool parseCoilMode(const std::string &value, domain::CoilMode *mode, ParseState *state) | |||
| bool parseCoilMode(const std::string &value, CoilMode *mode, ParseState *state) | |||
| { | |||
| if (value == "normal") | |||
| { | |||
| *mode = domain::CoilMode::Normal; | |||
| *mode = CoilMode::Normal; | |||
| } | |||
| else if (value == "set") | |||
| { | |||
| *mode = domain::CoilMode::Set; | |||
| *mode = CoilMode::Set; | |||
| } | |||
| else if (value == "reset") | |||
| { | |||
| *mode = domain::CoilMode::Reset; | |||
| *mode = CoilMode::Reset; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "unsupported coil mode " + value); | |||
| } | |||
| return true; | |||
| } | |||
| // 将比较运算符枚举转换为工程文件中的稳定字符串 | |||
| QString comparisonName(domain::ComparisonOperator comparison) | |||
| QString comparisonName(ComparisonOperator comparison) | |||
| { | |||
| switch (comparison) | |||
| { | |||
| case domain::ComparisonOperator::Equal: | |||
| case ComparisonOperator::Equal: | |||
| { | |||
| return QStringLiteral("equal"); | |||
| } | |||
| case domain::ComparisonOperator::NotEqual: | |||
| case ComparisonOperator::NotEqual: | |||
| { | |||
| return QStringLiteral("notEqual"); | |||
| } | |||
| case domain::ComparisonOperator::LessThan: | |||
| case ComparisonOperator::LessThan: | |||
| { | |||
| return QStringLiteral("lessThan"); | |||
| } | |||
| case domain::ComparisonOperator::LessThanOrEqual: | |||
| case ComparisonOperator::LessThanOrEqual: | |||
| { | |||
| return QStringLiteral("lessThanOrEqual"); | |||
| } | |||
| case domain::ComparisonOperator::GreaterThan: | |||
| case ComparisonOperator::GreaterThan: | |||
| { | |||
| return QStringLiteral("greaterThan"); | |||
| } | |||
| case domain::ComparisonOperator::GreaterThanOrEqual: | |||
| case ComparisonOperator::GreaterThanOrEqual: | |||
| { | |||
| return QStringLiteral("greaterThanOrEqual"); | |||
| } | |||
| @@ -649,44 +647,44 @@ QString comparisonName(domain::ComparisonOperator comparison) | |||
| // 将工程文件中的比较运算符字符串转换为枚举 | |||
| bool parseComparison( | |||
| const std::string &value, | |||
| domain::ComparisonOperator *comparison, | |||
| ComparisonOperator *comparison, | |||
| ParseState *state) | |||
| { | |||
| if (value == "equal") | |||
| { | |||
| *comparison = domain::ComparisonOperator::Equal; | |||
| *comparison = ComparisonOperator::Equal; | |||
| } | |||
| else if (value == "notEqual") | |||
| { | |||
| *comparison = domain::ComparisonOperator::NotEqual; | |||
| *comparison = ComparisonOperator::NotEqual; | |||
| } | |||
| else if (value == "lessThan") | |||
| { | |||
| *comparison = domain::ComparisonOperator::LessThan; | |||
| *comparison = ComparisonOperator::LessThan; | |||
| } | |||
| else if (value == "lessThanOrEqual") | |||
| { | |||
| *comparison = domain::ComparisonOperator::LessThanOrEqual; | |||
| *comparison = ComparisonOperator::LessThanOrEqual; | |||
| } | |||
| else if (value == "greaterThan") | |||
| { | |||
| *comparison = domain::ComparisonOperator::GreaterThan; | |||
| *comparison = ComparisonOperator::GreaterThan; | |||
| } | |||
| else if (value == "greaterThanOrEqual") | |||
| { | |||
| *comparison = domain::ComparisonOperator::GreaterThanOrEqual; | |||
| *comparison = ComparisonOperator::GreaterThanOrEqual; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "unsupported comparison operator " + value); | |||
| } | |||
| return true; | |||
| } | |||
| // 将触点节点配置序列化,并写入用于反序列化分派的类型标记 | |||
| QJsonObject serializeNodeConfig(const domain::ContactNodeConfig &config) | |||
| QJsonObject serializeNodeConfig(const ContactNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("type"), QStringLiteral("contact")); | |||
| @@ -696,7 +694,7 @@ QJsonObject serializeNodeConfig(const domain::ContactNodeConfig &config) | |||
| } | |||
| // 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 | |||
| QJsonObject serializeNodeConfig(const domain::CoilNodeConfig &config) | |||
| QJsonObject serializeNodeConfig(const CoilNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("type"), QStringLiteral("coil")); | |||
| @@ -706,7 +704,7 @@ QJsonObject serializeNodeConfig(const domain::CoilNodeConfig &config) | |||
| } | |||
| // 将数值比较节点配置序列化,并保留有符号 16 位比较常量 | |||
| QJsonObject serializeNodeConfig(const domain::CompareNodeConfig &config) | |||
| QJsonObject serializeNodeConfig(const CompareNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("type"), QStringLiteral("compare")); | |||
| @@ -717,7 +715,7 @@ QJsonObject serializeNodeConfig(const domain::CompareNodeConfig &config) | |||
| } | |||
| // 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载 | |||
| QJsonObject serializeLogicNode(const domain::LogicNode &node) | |||
| QJsonObject serializeLogicNode(const LogicNode &node) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(node.id)); | |||
| @@ -737,7 +735,7 @@ QJsonObject serializeLogicNode(const domain::LogicNode &node) | |||
| bool parseNodeConfig( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::LogicNodeConfig *config, | |||
| LogicNodeConfig *config, | |||
| ParseState *state) | |||
| { | |||
| std::string type; | |||
| @@ -748,7 +746,7 @@ bool parseNodeConfig( | |||
| return false; | |||
| } | |||
| domain::RegisterAddress address{domain::RegisterArea::M, 0}; | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| if (!parseAddress(address_object, context + ".address", &address, state)) | |||
| { | |||
| return false; | |||
| @@ -758,32 +756,32 @@ bool parseNodeConfig( | |||
| if (type == "contact") | |||
| { | |||
| std::string mode_text; | |||
| domain::ContactMode mode = domain::ContactMode::NormallyOpen; | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| if (!readString(object, "mode", context, &mode_text, state) | |||
| || !parseContactMode(mode_text, &mode, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *config = domain::ContactNodeConfig{address, mode}; | |||
| *config = ContactNodeConfig{address, mode}; | |||
| return true; | |||
| } | |||
| if (type == "coil") | |||
| { | |||
| std::string mode_text; | |||
| domain::CoilMode mode = domain::CoilMode::Normal; | |||
| CoilMode mode = CoilMode::Normal; | |||
| if (!readString(object, "mode", context, &mode_text, state) | |||
| || !parseCoilMode(mode_text, &mode, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *config = domain::CoilNodeConfig{address, mode}; | |||
| *config = CoilNodeConfig{address, mode}; | |||
| return true; | |||
| } | |||
| if (type == "compare") | |||
| { | |||
| std::string comparison_text; | |||
| int value = 0; | |||
| domain::ComparisonOperator comparison = domain::ComparisonOperator::Equal; | |||
| ComparisonOperator comparison = ComparisonOperator::Equal; | |||
| if (!readString(object, "comparison", context, &comparison_text, state) | |||
| || !readInt( | |||
| object, | |||
| @@ -798,13 +796,13 @@ bool parseNodeConfig( | |||
| return false; | |||
| } | |||
| // 先按 int 校验范围,再安全收窄为领域模型要求的 int16_t | |||
| *config = domain::CompareNodeConfig{ | |||
| *config = CompareNodeConfig{ | |||
| address, comparison, static_cast<std::int16_t>(value)}; | |||
| return true; | |||
| } | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "unsupported logic node type " + type); | |||
| } | |||
| @@ -812,7 +810,7 @@ bool parseNodeConfig( | |||
| bool parseLogicNode( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::LogicNode *node, | |||
| LogicNode *node, | |||
| ParseState *state) | |||
| { | |||
| QJsonObject config; | |||
| @@ -822,7 +820,7 @@ bool parseLogicNode( | |||
| } | |||
| // 将逻辑节点之间的有向连接序列化为 JSON 对象 | |||
| QJsonObject serializeConnection(const domain::LogicConnection &connection) | |||
| QJsonObject serializeConnection(const LogicConnection &connection) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("fromNodeId"), fromUtf8(connection.fromNodeId)); | |||
| @@ -834,7 +832,7 @@ QJsonObject serializeConnection(const domain::LogicConnection &connection) | |||
| bool parseConnection( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::LogicConnection *connection, | |||
| LogicConnection *connection, | |||
| ParseState *state) | |||
| { | |||
| return readString(object, "fromNodeId", context, &connection->fromNodeId, state) | |||
| @@ -842,16 +840,16 @@ bool parseConnection( | |||
| } | |||
| // 将一套控制逻辑及其节点和连接序列化为 JSON 对象 | |||
| QJsonObject serializeControlLogic(const domain::ControlLogic &logic) | |||
| QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| { | |||
| QJsonArray nodes; | |||
| for (const domain::LogicNode &node : logic.nodes) | |||
| for (const LogicNode &node : logic.nodes) | |||
| { | |||
| nodes.append(serializeLogicNode(node)); | |||
| } | |||
| QJsonArray connections; | |||
| for (const domain::LogicConnection &connection : logic.connections) | |||
| for (const LogicConnection &connection : logic.connections) | |||
| { | |||
| connections.append(serializeConnection(connection)); | |||
| } | |||
| @@ -869,7 +867,7 @@ QJsonObject serializeControlLogic(const domain::ControlLogic &logic) | |||
| bool parseControlLogic( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| domain::ControlLogic *logic, | |||
| ControlLogic *logic, | |||
| ParseState *state) | |||
| { | |||
| QJsonArray nodes; | |||
| @@ -890,10 +888,10 @@ bool parseControlLogic( | |||
| if (!nodes.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| context + ".nodes items must be objects"); | |||
| } | |||
| domain::LogicNode node; | |||
| LogicNode node; | |||
| if (!parseLogicNode( | |||
| nodes.at(index).toObject(), | |||
| context + ".nodes[" + std::to_string(index) + ']', | |||
| @@ -911,10 +909,10 @@ bool parseControlLogic( | |||
| if (!connections.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| context + ".connections items must be objects"); | |||
| } | |||
| domain::LogicConnection connection; | |||
| LogicConnection connection; | |||
| if (!parseConnection( | |||
| connections.at(index).toObject(), | |||
| context + ".connections[" + std::to_string(index) + ']', | |||
| @@ -929,16 +927,16 @@ bool parseControlLogic( | |||
| } | |||
| // 将完整领域工程聚合为工程文件的顶层 JSON 对象 | |||
| QJsonObject serializeProject(const domain::Project &project) | |||
| QJsonObject serializeProject(const Project &project) | |||
| { | |||
| QJsonArray pages; | |||
| for (const domain::HmiPage &page : project.hmiPages) | |||
| for (const HmiPage &page : project.hmiPages) | |||
| { | |||
| pages.append(serializeHmiPage(page)); | |||
| } | |||
| QJsonArray logics; | |||
| for (const domain::ControlLogic &logic : project.controlLogics) | |||
| for (const ControlLogic &logic : project.controlLogics) | |||
| { | |||
| logics.append(serializeControlLogic(logic)); | |||
| } | |||
| @@ -954,7 +952,7 @@ QJsonObject serializeProject(const domain::Project &project) | |||
| // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 | |||
| bool parseProject( | |||
| const QJsonObject &object, domain::Project *project, ParseState *state) | |||
| const QJsonObject &object, Project *project, ParseState *state) | |||
| { | |||
| QJsonArray pages; | |||
| QJsonArray logics; | |||
| @@ -971,7 +969,7 @@ bool parseProject( | |||
| if (project->metadata.formatVersion != kCurrentFormatVersion) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::UnsupportedVersion, | |||
| ProjectStorageError::UnsupportedVersion, | |||
| "unsupported project format version " + project->metadata.formatVersion); | |||
| } | |||
| @@ -990,10 +988,10 @@ bool parseProject( | |||
| if (!pages.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "project.hmiPages items must be objects"); | |||
| } | |||
| domain::HmiPage page; | |||
| HmiPage page; | |||
| if (!parseHmiPage( | |||
| pages.at(index).toObject(), | |||
| "project.hmiPages[" + std::to_string(index) + ']', | |||
| @@ -1011,10 +1009,10 @@ bool parseProject( | |||
| if (!logics.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| domain::ProjectStorageError::InvalidField, | |||
| ProjectStorageError::InvalidField, | |||
| "project.controlLogics items must be objects"); | |||
| } | |||
| domain::ControlLogic logic; | |||
| ControlLogic logic; | |||
| if (!parseControlLogic( | |||
| logics.at(index).toObject(), | |||
| "project.controlLogics[" + std::to_string(index) + ']', | |||
| @@ -1029,8 +1027,8 @@ bool parseProject( | |||
| } | |||
| // 将 Qt 文件错误转换为领域层统一的工程保存失败结果 | |||
| domain::ProjectSaveResult saveFailure( | |||
| domain::ProjectStorageError error, const QString &message) | |||
| ProjectSaveResult saveFailure( | |||
| ProjectStorageError error, const QString &message) | |||
| { | |||
| return {false, error, toUtf8(message)}; | |||
| } | |||
| @@ -1038,19 +1036,19 @@ domain::ProjectSaveResult saveFailure( | |||
| } // namespace | |||
| // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 | |||
| domain::ProjectSaveResult JsonProjectStorage::save( | |||
| const domain::Project &project, const std::string &file_path) | |||
| ProjectSaveResult JsonProjectStorage::save( | |||
| const Project &project, const std::string &file_path) | |||
| { | |||
| // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 | |||
| std::string validation_error; | |||
| if (!project.validate(&validation_error)) | |||
| { | |||
| return {false, domain::ProjectStorageError::InvalidProject, validation_error}; | |||
| return {false, ProjectStorageError::InvalidProject, validation_error}; | |||
| } | |||
| if (project.metadata.formatVersion != kCurrentFormatVersion) | |||
| { | |||
| return {false, | |||
| domain::ProjectStorageError::UnsupportedVersion, | |||
| ProjectStorageError::UnsupportedVersion, | |||
| "unsupported project format version " + project.metadata.formatVersion}; | |||
| } | |||
| @@ -1058,7 +1056,7 @@ domain::ProjectSaveResult JsonProjectStorage::save( | |||
| QSaveFile file(fromUtf8(file_path)); | |||
| if (!file.open(QIODevice::WriteOnly)) | |||
| { | |||
| return saveFailure(domain::ProjectStorageError::FileOpenFailed, file.errorString()); | |||
| return saveFailure(ProjectStorageError::FileOpenFailed, file.errorString()); | |||
| } | |||
| const QByteArray data = QJsonDocument(serializeProject(project)).toJson( | |||
| @@ -1067,24 +1065,24 @@ domain::ProjectSaveResult JsonProjectStorage::save( | |||
| if (file.write(data) != data.size()) | |||
| { | |||
| file.cancelWriting(); | |||
| return saveFailure(domain::ProjectStorageError::FileWriteFailed, file.errorString()); | |||
| return saveFailure(ProjectStorageError::FileWriteFailed, file.errorString()); | |||
| } | |||
| if (!file.commit()) | |||
| { | |||
| return saveFailure(domain::ProjectStorageError::FileWriteFailed, file.errorString()); | |||
| return saveFailure(ProjectStorageError::FileWriteFailed, file.errorString()); | |||
| } | |||
| return {true, domain::ProjectStorageError::None, {}}; | |||
| return {true, ProjectStorageError::None, {}}; | |||
| } | |||
| // 读取并解析工程文件,全部校验通过后才返回新的领域工程 | |||
| domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| { | |||
| QFile file(fromUtf8(file_path)); | |||
| if (!file.open(QIODevice::ReadOnly)) | |||
| { | |||
| return {false, | |||
| {}, | |||
| domain::ProjectStorageError::FileOpenFailed, | |||
| ProjectStorageError::FileOpenFailed, | |||
| toUtf8(file.errorString())}; | |||
| } | |||
| @@ -1093,7 +1091,7 @@ domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| { | |||
| return {false, | |||
| {}, | |||
| domain::ProjectStorageError::FileReadFailed, | |||
| ProjectStorageError::FileReadFailed, | |||
| toUtf8(file.errorString())}; | |||
| } | |||
| @@ -1107,12 +1105,12 @@ domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| : parse_error.errorString(); | |||
| return {false, | |||
| {}, | |||
| domain::ProjectStorageError::InvalidJson, | |||
| ProjectStorageError::InvalidJson, | |||
| toUtf8(message)}; | |||
| } | |||
| // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 | |||
| domain::Project project; | |||
| Project project; | |||
| ParseState state; | |||
| if (!parseProject(document.object(), &project, &state)) | |||
| { | |||
| @@ -1125,10 +1123,8 @@ domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| { | |||
| return {false, | |||
| {}, | |||
| domain::ProjectStorageError::InvalidProject, | |||
| ProjectStorageError::InvalidProject, | |||
| validation_error}; | |||
| } | |||
| return {true, std::move(project), domain::ProjectStorageError::None, {}}; | |||
| return {true, std::move(project), ProjectStorageError::None, {}}; | |||
| } | |||
| } // namespace integrated_platform::infrastructure | |||
| @@ -2,15 +2,11 @@ | |||
| #include "domain/project_storage.h" | |||
| namespace integrated_platform::infrastructure { | |||
| // 使用版本化 JSON 文件保存和加载工程 | |||
| class JsonProjectStorage final : public domain::ProjectStorage | |||
| class JsonProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| domain::ProjectSaveResult save( | |||
| const domain::Project &project, const std::string &file_path) override; | |||
| domain::ProjectLoadResult load(const std::string &file_path) override; | |||
| ProjectSaveResult save( | |||
| const Project &project, const std::string &file_path) override; | |||
| ProjectLoadResult load(const std::string &file_path) override; | |||
| }; | |||
| } // namespace integrated_platform::infrastructure | |||
| @@ -8,6 +8,11 @@ | |||
| #include <QApplication> | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "ui/main_window.h" | |||
| int main(int argc, char *argv[]) | |||
| @@ -16,7 +21,17 @@ int main(int argc, char *argv[]) | |||
| application.setApplicationName(QObject::tr("综合平台编程器")); | |||
| application.setOrganizationName(QStringLiteral("QtProXinJe")); | |||
| integrated_platform::MainWindow main_window; | |||
| JsonProjectStorage project_storage; | |||
| ProjectService project_service(project_storage); | |||
| HmiEditorService hmi_editor_service(project_service); | |||
| VirtualRegisterRepository virtual_register_repository; | |||
| HmiRuntimeService hmi_runtime_service(virtual_register_repository); | |||
| RuntimeModeService runtime_mode_service; | |||
| MainWindow main_window( | |||
| runtime_mode_service, | |||
| project_service, | |||
| hmi_editor_service, | |||
| hmi_runtime_service); | |||
| main_window.show(); | |||
| return application.exec(); | |||
| @@ -0,0 +1,365 @@ | |||
| #include "hmi_editor_service.h" | |||
| #include "project_service.h" | |||
| #include <algorithm> | |||
| #include <utility> | |||
| namespace { | |||
| void setError(std::string *error, const std::string &message) | |||
| { | |||
| if (error != nullptr) | |||
| { | |||
| *error = message; | |||
| } | |||
| } | |||
| std::string controlPrefix(HmiControlType type) | |||
| { | |||
| switch (type) | |||
| { | |||
| case HmiControlType::Button: | |||
| { | |||
| return "button"; | |||
| } | |||
| case HmiControlType::Indicator: | |||
| { | |||
| return "indicator"; | |||
| } | |||
| case HmiControlType::NumericDisplay: | |||
| { | |||
| return "numeric-display"; | |||
| } | |||
| case HmiControlType::NumericInput: | |||
| { | |||
| return "numeric-input"; | |||
| } | |||
| case HmiControlType::Label: | |||
| default: | |||
| { | |||
| return "label"; | |||
| } | |||
| } | |||
| } | |||
| std::string defaultText(HmiControlType type) | |||
| { | |||
| switch (type) | |||
| { | |||
| case HmiControlType::Button: | |||
| { | |||
| return "按钮"; | |||
| } | |||
| case HmiControlType::Indicator: | |||
| { | |||
| return "指示灯"; | |||
| } | |||
| case HmiControlType::NumericDisplay: | |||
| { | |||
| return "数值显示"; | |||
| } | |||
| case HmiControlType::NumericInput: | |||
| { | |||
| return "数值输入"; | |||
| } | |||
| case HmiControlType::Label: | |||
| default: | |||
| { | |||
| return "文本"; | |||
| } | |||
| } | |||
| } | |||
| HmiEditorResult failure(HmiEditorError error, const std::string &message) | |||
| { | |||
| return {false, error, message, {}}; | |||
| } | |||
| } // namespace | |||
| HmiEditorService::HmiEditorService(ProjectService &project_service) | |||
| : project_service_(project_service) | |||
| { | |||
| } | |||
| const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| const auto page = std::find_if( | |||
| project.hmiPages.cbegin(), | |||
| project.hmiPages.cend(), | |||
| [&page_id](const HmiPage &candidate) | |||
| { | |||
| return candidate.id == page_id; | |||
| }); | |||
| return page == project.hmiPages.cend() ? nullptr : &*page; | |||
| } | |||
| const HmiControl *HmiEditorService::findControl( | |||
| const std::string &page_id, const std::string &control_id) const | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| if (page == nullptr) | |||
| { | |||
| return nullptr; | |||
| } | |||
| const auto control = std::find_if( | |||
| page->controls.cbegin(), | |||
| page->controls.cend(), | |||
| [&control_id](const HmiControl &candidate) | |||
| { | |||
| return candidate.id == control_id; | |||
| }); | |||
| return control == page->controls.cend() ? nullptr : &*control; | |||
| } | |||
| std::string HmiEditorService::firstPageId() const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id; | |||
| } | |||
| HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| { | |||
| if (!project_service_.project().hmiPages.empty()) | |||
| { | |||
| return {true, HmiEditorError::None, {}, firstPageId()}; | |||
| } | |||
| HmiPage page; | |||
| page.id = "page-1"; | |||
| page.name = "主操作页面"; | |||
| Project &project = project_service_.editProject(); | |||
| project.hmiPages.push_back(std::move(page)); | |||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::addControl( | |||
| const std::string &page_id, HmiControlType type) | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| if (page == nullptr) | |||
| { | |||
| 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( | |||
| project.hmiPages.begin(), | |||
| project.hmiPages.end(), | |||
| [&page_id](const HmiPage &candidate) | |||
| { | |||
| return candidate.id == page_id; | |||
| }); | |||
| target_page->controls.push_back(std::move(control)); | |||
| return {true, | |||
| HmiEditorError::None, | |||
| {}, | |||
| target_page->controls.back().id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::removeControl( | |||
| const std::string &page_id, const std::string &control_id) | |||
| { | |||
| if (findPage(page_id) == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "HMI page was not found"); | |||
| } | |||
| if (findControl(page_id, control_id) == nullptr) | |||
| { | |||
| return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto page = std::find_if( | |||
| project.hmiPages.begin(), | |||
| project.hmiPages.end(), | |||
| [&page_id](const HmiPage &candidate) | |||
| { | |||
| return candidate.id == page_id; | |||
| }); | |||
| page->controls.erase( | |||
| std::remove_if( | |||
| page->controls.begin(), | |||
| page->controls.end(), | |||
| [&control_id](const HmiControl &candidate) | |||
| { | |||
| return candidate.id == control_id; | |||
| }), | |||
| page->controls.end()); | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::moveControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| const HmiRect &bounds) | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| const HmiControl *control = findControl(page_id, control_id); | |||
| if (page == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "HMI page was not found"); | |||
| } | |||
| if (control == nullptr) | |||
| { | |||
| return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); | |||
| } | |||
| HmiControl candidate = *control; | |||
| candidate.bounds = bounds; | |||
| std::string error; | |||
| if (!validateEditableControl(*page, candidate, &error)) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &item) { return item.id == page_id; }); | |||
| auto editable = std::find_if( | |||
| target->controls.begin(), target->controls.end(), | |||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | |||
| editable->bounds = bounds; | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::updateControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| const HmiControl &control) | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| const HmiControl *existing = findControl(page_id, control_id); | |||
| if (page == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "HMI page was not found"); | |||
| } | |||
| if (existing == nullptr) | |||
| { | |||
| return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); | |||
| } | |||
| if (control.type != existing->type) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, "HMI control type cannot be changed"); | |||
| } | |||
| std::string error; | |||
| if (!validateEditableControl(*page, control, &error)) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, error); | |||
| } | |||
| if (hasDuplicateControlId(*page, control_id, control.id)) | |||
| { | |||
| return failure(HmiEditorError::DuplicateId, "HMI control id must be unique within a page"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &item) { return item.id == page_id; }); | |||
| auto editable = std::find_if( | |||
| target->controls.begin(), target->controls.end(), | |||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | |||
| *editable = control; | |||
| return {true, HmiEditorError::None, {}, control.id}; | |||
| } | |||
| bool HmiEditorService::validateEditableControl( | |||
| const HmiPage &page, const HmiControl &control, std::string *error) | |||
| { | |||
| if (control.id.empty()) | |||
| { | |||
| setError(error, "HMI control id must not be empty"); | |||
| return false; | |||
| } | |||
| if (control.bounds.width <= 0 || control.bounds.height <= 0 | |||
| || control.bounds.x < 0 || control.bounds.y < 0 | |||
| || control.bounds.width > page.width || control.bounds.height > page.height | |||
| || control.bounds.x > page.width - control.bounds.width | |||
| || control.bounds.y > page.height - control.bounds.height) | |||
| { | |||
| setError(error, "HMI control bounds must stay within the page"); | |||
| return false; | |||
| } | |||
| for (const auto &property : control.properties) | |||
| { | |||
| if (property.first.empty()) | |||
| { | |||
| setError(error, "HMI control property names must not be empty"); | |||
| return false; | |||
| } | |||
| } | |||
| if (control.binding.has_value() && !control.binding->isValid()) | |||
| { | |||
| setError(error, "HMI control binding has an invalid address"); | |||
| return false; | |||
| } | |||
| if (control.binding.has_value() | |||
| && (control.type == HmiControlType::Button | |||
| || control.type == HmiControlType::Indicator) | |||
| && control.binding->area() != RegisterArea::M) | |||
| { | |||
| setError(error, "button and indicator controls require an M address"); | |||
| return false; | |||
| } | |||
| if (control.binding.has_value() | |||
| && (control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput) | |||
| && control.binding->area() != RegisterArea::D) | |||
| { | |||
| setError(error, "numeric controls require a D address"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool HmiEditorService::hasDuplicateControlId( | |||
| const HmiPage &page, | |||
| const std::string &excluded_id, | |||
| const std::string &candidate_id) | |||
| { | |||
| return std::any_of( | |||
| page.controls.cbegin(), page.controls.cend(), | |||
| [&excluded_id, &candidate_id](const HmiControl &item) | |||
| { | |||
| return item.id != excluded_id && item.id == candidate_id; | |||
| }); | |||
| } | |||
| HmiControl HmiEditorService::makeControl( | |||
| const HmiPage &page, HmiControlType type) | |||
| { | |||
| HmiControl control; | |||
| control.id = makeUniqueId(page, controlPrefix(type)); | |||
| control.type = type; | |||
| control.text = defaultText(type); | |||
| control.bounds.width = type == HmiControlType::Indicator ? 64 : 120; | |||
| control.bounds.height = type == HmiControlType::Indicator ? 64 : 40; | |||
| const int offset = static_cast<int>(page.controls.size()) * 16; | |||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | |||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | |||
| return control; | |||
| } | |||
| std::string HmiEditorService::makeUniqueId( | |||
| const HmiPage &page, const std::string &prefix) | |||
| { | |||
| int suffix = 1; | |||
| while (true) | |||
| { | |||
| const std::string candidate = prefix + '-' + std::to_string(suffix); | |||
| const bool found = std::any_of( | |||
| page.controls.cbegin(), page.controls.cend(), | |||
| [&candidate](const HmiControl &item) { return item.id == candidate; }); | |||
| if (!found) | |||
| { | |||
| return candidate; | |||
| } | |||
| ++suffix; | |||
| } | |||
| } | |||
| @@ -0,0 +1,135 @@ | |||
| /** | |||
| * @file hmi_editor_service.h | |||
| * @brief 定义 HMI 页面和控件的编辑用例服务 | |||
| * @author suyu | |||
| * @date 2026-08-10 | |||
| */ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include <string> | |||
| class ProjectService; | |||
| /** | |||
| * @brief HMI 编辑操作的失败分类 | |||
| */ | |||
| enum class HmiEditorError | |||
| { | |||
| None, | |||
| PageNotFound, | |||
| ControlNotFound, | |||
| DuplicateId, | |||
| InvalidPage, | |||
| InvalidControl | |||
| }; | |||
| /** | |||
| * @brief HMI 编辑操作的统一结果 | |||
| * | |||
| * 成功时 `id` 保存新建或更新后的控件或页面标识 | |||
| */ | |||
| struct HmiEditorResult | |||
| { | |||
| bool succeeded = false; | |||
| HmiEditorError error = HmiEditorError::None; | |||
| std::string message; | |||
| std::string id; | |||
| }; | |||
| /** | |||
| * @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图 | |||
| * | |||
| * 服务通过 ProjectService 修改工程并负责标记工程为已修改 | |||
| */ | |||
| class HmiEditorService | |||
| { | |||
| public: | |||
| explicit HmiEditorService(ProjectService &project_service); | |||
| /** | |||
| * @brief 按页面标识查找只读页面 | |||
| * @param page_id 页面唯一标识 | |||
| * @return 找到时返回页面指针,未找到时返回空指针 | |||
| */ | |||
| const HmiPage *findPage(const std::string &page_id) const; | |||
| /** | |||
| * @brief 按页面和控件标识查找只读控件 | |||
| * @param page_id 所属页面唯一标识 | |||
| * @param control_id 控件唯一标识 | |||
| * @return 找到时返回控件指针,任一标识不存在时返回空指针 | |||
| */ | |||
| const HmiControl *findControl( | |||
| const std::string &page_id, const std::string &control_id) const; | |||
| /** | |||
| * @brief 返回工程中第一个 HMI 页面标识 | |||
| * @return 工程没有 HMI 页面时返回空字符串 | |||
| */ | |||
| std::string firstPageId() const; | |||
| /** | |||
| * @brief 在工程没有 HMI 页面时创建默认操作页 | |||
| * @return 已有或新建页面的成功结果及其页面标识 | |||
| */ | |||
| HmiEditorResult ensureDefaultPage(); | |||
| /** | |||
| * @brief 向指定页面添加待配置的基础控件 | |||
| * @param page_id 目标页面唯一标识 | |||
| * @param type 新控件类型 | |||
| * @return 成功时返回新控件标识,页面不存在时返回 PageNotFound | |||
| * | |||
| * 新控件可暂时没有寄存器绑定,完整绑定校验在工程保存前执行 | |||
| */ | |||
| HmiEditorResult addControl( | |||
| const std::string &page_id, HmiControlType type); | |||
| /** | |||
| * @brief 删除指定页面中的控件 | |||
| * @param page_id 所属页面唯一标识 | |||
| * @param control_id 待删除控件唯一标识 | |||
| * @return 页面或控件不存在时返回对应错误 | |||
| */ | |||
| HmiEditorResult removeControl( | |||
| const std::string &page_id, const std::string &control_id); | |||
| /** | |||
| * @brief 更新控件位置和尺寸 | |||
| * @param page_id 所属页面唯一标识 | |||
| * @param control_id 待移动控件唯一标识 | |||
| * @param bounds 新矩形,必须完整位于页面范围内 | |||
| * @return 页面、控件或矩形无效时返回失败结果 | |||
| */ | |||
| HmiEditorResult moveControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| const HmiRect &bounds); | |||
| /** | |||
| * @brief 更新控件可编辑属性 | |||
| * @param page_id 所属页面唯一标识 | |||
| * @param control_id 原控件唯一标识 | |||
| * @param control 更新后的控件配置,不允许改变控件类型 | |||
| * @return 控件标识重复、绑定区域错误或矩形越界时返回失败结果 | |||
| * | |||
| * 允许控件暂时没有必需绑定,完整工程校验仍在保存前执行 | |||
| */ | |||
| HmiEditorResult updateControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| const HmiControl &control); | |||
| private: | |||
| static bool validateEditableControl( | |||
| const HmiPage &page, | |||
| const HmiControl &control, | |||
| std::string *error); | |||
| static bool hasDuplicateControlId( | |||
| const HmiPage &page, | |||
| const std::string &excluded_id, | |||
| const std::string &candidate_id); | |||
| static HmiControl makeControl( | |||
| const HmiPage &page, HmiControlType type); | |||
| static std::string makeUniqueId( | |||
| const HmiPage &page, const std::string &prefix); | |||
| ProjectService &project_service_; | |||
| }; | |||
| @@ -0,0 +1,124 @@ | |||
| #include "hmi_runtime_service.h" | |||
| namespace { | |||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | |||
| { | |||
| return {false, error, false, 0}; | |||
| } | |||
| HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||
| { | |||
| return {false, error}; | |||
| } | |||
| bool isBitControl(HmiControlType type) | |||
| { | |||
| return type == HmiControlType::Button || type == HmiControlType::Indicator; | |||
| } | |||
| bool isWordControl(HmiControlType type) | |||
| { | |||
| return type == HmiControlType::NumericDisplay | |||
| || type == HmiControlType::NumericInput; | |||
| } | |||
| } // namespace | |||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||
| : repository_(repository) | |||
| { | |||
| } | |||
| HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | |||
| { | |||
| if (!isBitControl(control.type) && !isWordControl(control.type)) | |||
| { | |||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||
| } | |||
| if (!control.binding.has_value()) | |||
| { | |||
| return readFailure(HmiRuntimeError::MissingBinding); | |||
| } | |||
| if (!control.binding->isValid()) | |||
| { | |||
| return readFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| if (isBitControl(control.type)) | |||
| { | |||
| const BitReadResult result = repository_.readBit(*control.binding); | |||
| if (!result.succeeded) | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, result.value, 0}; | |||
| } | |||
| if (isWordControl(control.type)) | |||
| { | |||
| const WordReadResult result = repository_.readWord(*control.binding); | |||
| if (!result.succeeded) | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, false, result.value}; | |||
| } | |||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||
| } | |||
| HmiRuntimeWriteResult HmiRuntimeService::toggleButton(const HmiControl &control) | |||
| { | |||
| if (control.type != HmiControlType::Button) | |||
| { | |||
| return writeFailure(HmiRuntimeError::UnsupportedControl); | |||
| } | |||
| const HmiRuntimeReadResult current = readControl(control); | |||
| if (!current.succeeded) | |||
| { | |||
| return writeFailure(current.error); | |||
| } | |||
| const RegisterWriteResult result = repository_.writeBit( | |||
| *control.binding, !current.bit_value); | |||
| return result.succeeded | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| } | |||
| HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| const HmiControl &control, std::int16_t value) | |||
| { | |||
| if (control.type != HmiControlType::NumericInput) | |||
| { | |||
| return writeFailure(HmiRuntimeError::UnsupportedControl); | |||
| } | |||
| if (!control.binding.has_value()) | |||
| { | |||
| return writeFailure(HmiRuntimeError::MissingBinding); | |||
| } | |||
| if (!control.binding->isValid()) | |||
| { | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| const RegisterWriteResult result = repository_.writeWord(*control.binding, value); | |||
| return result.succeeded | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| } | |||
| HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error) | |||
| { | |||
| switch (error) | |||
| { | |||
| case RegisterError::InvalidAddress: | |||
| case RegisterError::AreaMismatch: | |||
| { | |||
| return HmiRuntimeError::InvalidBinding; | |||
| } | |||
| case RegisterError::None: | |||
| case RegisterError::Unavailable: | |||
| case RegisterError::WriteRejected: | |||
| default: | |||
| { | |||
| return HmiRuntimeError::RepositoryFailure; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,86 @@ | |||
| /** | |||
| * @file hmi_runtime_service.h | |||
| * @brief 定义仅依赖统一寄存器仓库的 HMI 运行态读写服务 | |||
| * @author suyu | |||
| * @date 2026-08-10 | |||
| */ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include <cstdint> | |||
| /** | |||
| * @brief HMI 运行态读写失败分类 | |||
| */ | |||
| enum class HmiRuntimeError | |||
| { | |||
| None, | |||
| UnsupportedControl, | |||
| MissingBinding, | |||
| InvalidBinding, | |||
| RepositoryFailure | |||
| }; | |||
| /** | |||
| * @brief HMI 控件寄存器读取结果 | |||
| * | |||
| * 控件类型决定 `bit_value` 或 `word_value` 哪一个有效 | |||
| */ | |||
| struct HmiRuntimeReadResult | |||
| { | |||
| bool succeeded = false; | |||
| HmiRuntimeError error = HmiRuntimeError::None; | |||
| bool bit_value = false; | |||
| std::int16_t word_value = 0; | |||
| }; | |||
| /** | |||
| * @brief HMI 控件寄存器写入结果 | |||
| */ | |||
| struct HmiRuntimeWriteResult | |||
| { | |||
| bool succeeded = false; | |||
| HmiRuntimeError error = HmiRuntimeError::None; | |||
| }; | |||
| /** | |||
| * @brief 仅通过统一寄存器仓库执行 HMI 运行态读写 | |||
| * | |||
| * 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库 | |||
| */ | |||
| class HmiRuntimeService | |||
| { | |||
| public: | |||
| explicit HmiRuntimeService(RegisterRepository &repository); | |||
| /** | |||
| * @brief 读取控件绑定的寄存器当前值 | |||
| * @param control 待读取控件 | |||
| * @return 按钮和指示灯返回 M 位,数值控件返回 D 字,标签返回 UnsupportedControl | |||
| * | |||
| * 控件未绑定、绑定区域错误或仓库不可用时不返回有效值 | |||
| */ | |||
| HmiRuntimeReadResult readControl(const HmiControl &control) const; | |||
| /** | |||
| * @brief 切换按钮当前绑定 M 位的值 | |||
| * @param control 按钮控件,必须绑定有效 M 地址 | |||
| * @return 非按钮、绑定无效或仓库拒绝写入时返回失败结果 | |||
| */ | |||
| HmiRuntimeWriteResult toggleButton(const HmiControl &control); | |||
| /** | |||
| * @brief 将带符号 16 位数值写入数值输入控件绑定的 D 地址 | |||
| * @param control 数值输入控件,必须绑定有效 D 地址 | |||
| * @param value 要写入的 D 字值 | |||
| * @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果 | |||
| */ | |||
| HmiRuntimeWriteResult writeNumericInput( | |||
| const HmiControl &control, std::int16_t value); | |||
| private: | |||
| static HmiRuntimeError repositoryError(RegisterError error); | |||
| RegisterRepository &repository_; | |||
| }; | |||
| @@ -7,8 +7,6 @@ | |||
| #include <sstream> | |||
| #include <utility> | |||
| namespace integrated_platform::services { | |||
| namespace { | |||
| // 使用高精度时间戳和随机值生成工程标识 | |||
| @@ -26,18 +24,18 @@ std::string generateProjectId() | |||
| } // namespace | |||
| ProjectService::ProjectService(domain::ProjectStorage &storage) | |||
| ProjectService::ProjectService(ProjectStorage &storage) | |||
| : storage_(storage), | |||
| project_(makeNewProject("Untitled")) | |||
| { | |||
| } | |||
| const domain::Project &ProjectService::project() const | |||
| const Project &ProjectService::project() const | |||
| { | |||
| return project_; | |||
| } | |||
| domain::Project &ProjectService::editProject() | |||
| Project &ProjectService::editProject() | |||
| { | |||
| modified_ = true; | |||
| return project_; | |||
| @@ -64,7 +62,7 @@ ProjectOperationResult ProjectService::createNewProject(const std::string &name) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::InvalidProjectName, | |||
| domain::ProjectStorageError::None, | |||
| ProjectStorageError::None, | |||
| "project name must not be empty"}; | |||
| } | |||
| @@ -72,7 +70,7 @@ ProjectOperationResult ProjectService::createNewProject(const std::string &name) | |||
| project_ = makeNewProject(name); | |||
| current_file_path_.clear(); | |||
| modified_ = true; | |||
| return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| ProjectOperationResult ProjectService::save() | |||
| @@ -81,7 +79,7 @@ ProjectOperationResult ProjectService::save() | |||
| { | |||
| return {false, | |||
| ProjectServiceError::FilePathRequired, | |||
| domain::ProjectStorageError::None, | |||
| ProjectStorageError::None, | |||
| "project file path is required"}; | |||
| } | |||
| return saveAs(current_file_path_); | |||
| @@ -93,7 +91,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::FilePathRequired, | |||
| domain::ProjectStorageError::None, | |||
| ProjectStorageError::None, | |||
| "project file path is required"}; | |||
| } | |||
| @@ -102,12 +100,12 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::InvalidProject, | |||
| domain::ProjectStorageError::InvalidProject, | |||
| ProjectStorageError::InvalidProject, | |||
| validation_error}; | |||
| } | |||
| // 先完成领域校验,避免将非法工程交给存储层 | |||
| const domain::ProjectSaveResult result = storage_.save(project_, file_path); | |||
| const ProjectSaveResult result = storage_.save(project_, file_path); | |||
| if (!result.succeeded) | |||
| { | |||
| return storageFailure(result.error, result.message); | |||
| @@ -116,7 +114,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||
| // 只有存储成功后才更新文件关联和未保存状态 | |||
| current_file_path_ = file_path; | |||
| modified_ = false; | |||
| return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| @@ -125,11 +123,11 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::FilePathRequired, | |||
| domain::ProjectStorageError::None, | |||
| ProjectStorageError::None, | |||
| "project file path is required"}; | |||
| } | |||
| domain::ProjectLoadResult result = storage_.load(file_path); | |||
| ProjectLoadResult result = storage_.load(file_path); | |||
| if (!result.succeeded) | |||
| { | |||
| return storageFailure(result.error, result.message); | |||
| @@ -140,7 +138,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::InvalidProject, | |||
| domain::ProjectStorageError::InvalidProject, | |||
| ProjectStorageError::InvalidProject, | |||
| validation_error}; | |||
| } | |||
| @@ -148,12 +146,12 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| project_ = std::move(result.project); | |||
| current_file_path_ = file_path; | |||
| modified_ = false; | |||
| return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| domain::Project ProjectService::makeNewProject(const std::string &name) | |||
| Project ProjectService::makeNewProject(const std::string &name) | |||
| { | |||
| domain::Project project; | |||
| Project project; | |||
| project.metadata.id = generateProjectId(); | |||
| project.metadata.name = name; | |||
| project.metadata.formatVersion = "1.0"; | |||
| @@ -173,9 +171,7 @@ bool ProjectService::isBlank(const std::string &value) | |||
| } | |||
| ProjectOperationResult ProjectService::storageFailure( | |||
| domain::ProjectStorageError error, const std::string &message) | |||
| ProjectStorageError error, const std::string &message) | |||
| { | |||
| return {false, ProjectServiceError::StorageFailure, error, message}; | |||
| } | |||
| } // namespace integrated_platform::services | |||
| @@ -4,8 +4,6 @@ | |||
| #include <string> | |||
| namespace integrated_platform::services { | |||
| // 工程管理操作失败原因 | |||
| enum class ProjectServiceError | |||
| { | |||
| @@ -21,7 +19,7 @@ struct ProjectOperationResult | |||
| { | |||
| bool succeeded = false; // 操作是否成功 | |||
| ProjectServiceError error = ProjectServiceError::None; // 服务层错误原因 | |||
| domain::ProjectStorageError storageError = domain::ProjectStorageError::None; // 存储层错误原因 | |||
| ProjectStorageError storageError = ProjectStorageError::None; // 存储层错误原因 | |||
| std::string message; // 面向调用方的结果描述 | |||
| }; | |||
| @@ -30,10 +28,10 @@ class ProjectService | |||
| { | |||
| public: | |||
| // 使用外部提供的工程存储实现创建服务 | |||
| explicit ProjectService(domain::ProjectStorage &storage); | |||
| explicit ProjectService(ProjectStorage &storage); | |||
| const domain::Project &project() const; | |||
| domain::Project &editProject(); | |||
| const Project &project() const; | |||
| Project &editProject(); | |||
| const std::string ¤tFilePath() const; | |||
| bool hasCurrentFile() const; | |||
| bool isModified() const; | |||
| @@ -66,15 +64,13 @@ public: | |||
| ProjectOperationResult load(const std::string &file_path); | |||
| private: | |||
| static domain::Project makeNewProject(const std::string &name); | |||
| static Project makeNewProject(const std::string &name); | |||
| static bool isBlank(const std::string &value); | |||
| static ProjectOperationResult storageFailure( | |||
| domain::ProjectStorageError error, const std::string &message); | |||
| ProjectStorageError error, const std::string &message); | |||
| domain::ProjectStorage &storage_; // 非拥有的工程存储依赖 | |||
| domain::Project project_; // 当前编辑中的工程 | |||
| ProjectStorage &storage_; // 非拥有的工程存储依赖 | |||
| Project project_; // 当前编辑中的工程 | |||
| std::string current_file_path_; // 当前工程对应的文件路径 | |||
| bool modified_ = true; // 当前工程是否有未保存修改 | |||
| }; | |||
| } // namespace integrated_platform::services | |||
| @@ -0,0 +1,44 @@ | |||
| /** | |||
| * @file runtime_mode_service.cpp | |||
| * @brief 实现运行模式服务 | |||
| * @version 0.1.0 | |||
| * @author suyu | |||
| * @date 2026-08-08 | |||
| */ | |||
| #include "runtime_mode_service.h" | |||
| ApplicationMode RuntimeModeService::mode() const | |||
| { | |||
| return state_.mode(); | |||
| } | |||
| ModePolicy RuntimeModeService::policy() const | |||
| { | |||
| return state_.policy(); | |||
| } | |||
| ModeTransitionResult RuntimeModeService::enterEditing() | |||
| { | |||
| return state_.enterEditing(); | |||
| } | |||
| ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| { | |||
| return state_.enterOfflineRunning(); | |||
| } | |||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | |||
| { | |||
| return state_.enterOnlineRunning(initial_plc_read_completed_); | |||
| } | |||
| void RuntimeModeService::setInitialPlcReadCompleted(bool completed) | |||
| { | |||
| initial_plc_read_completed_ = completed; | |||
| } | |||
| bool RuntimeModeService::initialPlcReadCompleted() const | |||
| { | |||
| return initial_plc_read_completed_; | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| /** | |||
| * @file runtime_mode_service.h | |||
| * @brief 定义面向 UI 的运行模式服务契约 | |||
| * @version 0.1.0 | |||
| * @author suyu | |||
| * @date 2026-08-08 | |||
| */ | |||
| #pragma once | |||
| #include "domain/runtime_state.h" | |||
| // 隔离 UI 与领域状态机并编排进入真机运行态的前置条件 | |||
| class RuntimeModeService | |||
| { | |||
| public: | |||
| ApplicationMode mode() const; | |||
| ModePolicy policy() const; | |||
| ModeTransitionResult enterEditing(); | |||
| ModeTransitionResult enterOfflineRunning(); | |||
| ModeTransitionResult enterOnlineRunning(); | |||
| // 由后续 PLC 通信服务在首次读取成功或缓存失效时更新 | |||
| void setInitialPlcReadCompleted(bool completed); | |||
| bool initialPlcReadCompleted() const; | |||
| private: | |||
| RuntimeState state_; | |||
| // 表示 PLC 缓存是否已通过至少一次有效读取建立 | |||
| bool initial_plc_read_completed_ = false; | |||
| }; | |||
| @@ -0,0 +1,476 @@ | |||
| #include "hmi_editor_widget.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include <QGraphicsItem> | |||
| #include <QGraphicsRectItem> | |||
| #include <QGraphicsScene> | |||
| #include <QGraphicsSceneMouseEvent> | |||
| #include <QInputDialog> | |||
| #include <QPainter> | |||
| #include <QResizeEvent> | |||
| #include <QStyleOptionGraphicsItem> | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <cstdint> | |||
| #include <functional> | |||
| #include <limits> | |||
| namespace { | |||
| class HmiGraphicsItem final : public QGraphicsItem | |||
| { | |||
| public: | |||
| HmiGraphicsItem( | |||
| const HmiControl &control, | |||
| int page_width, | |||
| int page_height, | |||
| std::function<void(const std::string &, const QPointF &)> moved, | |||
| std::function<void(const std::string &)> activated) | |||
| : control_(control), | |||
| page_width_(page_width), | |||
| page_height_(page_height), | |||
| moved_(std::move(moved)), | |||
| activated_(std::move(activated)) | |||
| { | |||
| setPos(control_.bounds.x, control_.bounds.y); | |||
| setFlag(ItemIsSelectable, true); | |||
| setAcceptedMouseButtons(Qt::LeftButton); | |||
| } | |||
| QRectF boundingRect() const override | |||
| { | |||
| return {0, | |||
| 0, | |||
| static_cast<qreal>(control_.bounds.width), | |||
| static_cast<qreal>(control_.bounds.height)}; | |||
| } | |||
| void paint( | |||
| QPainter *painter, | |||
| 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)); | |||
| switch (control_.type) | |||
| { | |||
| case HmiControlType::Button: | |||
| { | |||
| painter->setBrush(QColor(QStringLiteral("#dcece3"))); | |||
| painter->drawRoundedRect(rect, 4, 4); | |||
| painter->setPen(QColor(QStringLiteral("#205c3b"))); | |||
| painter->drawText(rect, Qt::AlignCenter, textWithValue()); | |||
| break; | |||
| } | |||
| case HmiControlType::Indicator: | |||
| { | |||
| const qreal diameter = std::min(rect.width(), rect.height() - 18.0); | |||
| const QRectF lamp( | |||
| rect.center().x() - diameter / 2.0, | |||
| rect.top() + 2, | |||
| diameter, | |||
| diameter); | |||
| painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148")) | |||
| : QColor(QStringLiteral("#b8c1c8"))); | |||
| painter->drawEllipse(lamp); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->drawText( | |||
| QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16), | |||
| Qt::AlignCenter, | |||
| QString::fromUtf8(control_.text.data(), | |||
| static_cast<int>(control_.text.size()))); | |||
| break; | |||
| } | |||
| case HmiControlType::NumericDisplay: | |||
| { | |||
| painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | |||
| painter->drawRect(rect); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | |||
| Qt::AlignVCenter | Qt::AlignLeft, | |||
| textWithValue()); | |||
| break; | |||
| } | |||
| case HmiControlType::NumericInput: | |||
| { | |||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||
| painter->drawRoundedRect(rect, 3, 3); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | |||
| Qt::AlignVCenter | Qt::AlignLeft, | |||
| textWithValue()); | |||
| break; | |||
| } | |||
| case HmiControlType::Label: | |||
| default: | |||
| { | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->drawText(rect, Qt::AlignCenter, | |||
| QString::fromUtf8(control_.text.data(), | |||
| static_cast<int>(control_.text.size()))); | |||
| break; | |||
| } | |||
| } | |||
| 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); | |||
| } | |||
| void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) | |||
| { | |||
| bit_value_ = bit_value; | |||
| word_value_ = word_value; | |||
| has_runtime_value_ = available; | |||
| update(); | |||
| } | |||
| protected: | |||
| QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | |||
| { | |||
| if (change == ItemPositionChange && flags().testFlag(ItemIsMovable)) | |||
| { | |||
| QPointF position = value.toPointF(); | |||
| const qreal maximum_x = std::max( | |||
| 0.0, static_cast<double>(page_width_ - control_.bounds.width)); | |||
| const qreal maximum_y = std::max( | |||
| 0.0, static_cast<double>(page_height_ - control_.bounds.height)); | |||
| position.setX(std::clamp(position.x(), 0.0, maximum_x)); | |||
| position.setY(std::clamp(position.y(), 0.0, maximum_y)); | |||
| return position; | |||
| } | |||
| return QGraphicsItem::itemChange(change, value); | |||
| } | |||
| void mousePressEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| if (!flags().testFlag(ItemIsMovable) | |||
| && control_.type == HmiControlType::Button && activated_) | |||
| { | |||
| setSelected(true); | |||
| activated_(control_.id); | |||
| event->accept(); | |||
| return; | |||
| } | |||
| QGraphicsItem::mousePressEvent(event); | |||
| } | |||
| void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| if (!flags().testFlag(ItemIsMovable) | |||
| && control_.type == HmiControlType::NumericInput && activated_) | |||
| { | |||
| setSelected(true); | |||
| activated_(control_.id); | |||
| event->accept(); | |||
| return; | |||
| } | |||
| QGraphicsItem::mouseDoubleClickEvent(event); | |||
| } | |||
| void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| QGraphicsItem::mouseReleaseEvent(event); | |||
| if (flags().testFlag(ItemIsMovable) && moved_) | |||
| { | |||
| moved_(control_.id, pos()); | |||
| } | |||
| } | |||
| private: | |||
| QString textWithValue() const | |||
| { | |||
| const QString text = QString::fromUtf8( | |||
| control_.text.data(), static_cast<int>(control_.text.size())); | |||
| if (!has_runtime_value_ || control_.type == HmiControlType::Button) | |||
| { | |||
| return text; | |||
| } | |||
| if (control_.type == HmiControlType::NumericDisplay | |||
| || control_.type == HmiControlType::NumericInput) | |||
| { | |||
| return text + QStringLiteral(": ") + QString::number(word_value_); | |||
| } | |||
| return text; | |||
| } | |||
| HmiControl control_; | |||
| int page_width_ = 0; | |||
| int page_height_ = 0; | |||
| std::function<void(const std::string &, const QPointF &)> moved_; | |||
| std::function<void(const std::string &)> activated_; | |||
| bool bit_value_ = false; | |||
| std::int16_t word_value_ = 0; | |||
| bool has_runtime_value_ = false; | |||
| }; | |||
| HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | |||
| { | |||
| return dynamic_cast<HmiGraphicsItem *>(item); | |||
| } | |||
| } // namespace | |||
| HmiEditorWidget::HmiEditorWidget( | |||
| HmiEditorService &editor_service, | |||
| HmiRuntimeService &runtime_service, | |||
| QWidget *parent) | |||
| : QGraphicsView(parent), | |||
| editor_service_(editor_service), | |||
| runtime_service_(runtime_service), | |||
| scene_(new QGraphicsScene(this)) | |||
| { | |||
| setObjectName(QStringLiteral("hmiEditorWidget")); | |||
| setScene(scene_); | |||
| setRenderHint(QPainter::Antialiasing, true); | |||
| setDragMode(QGraphicsView::RubberBandDrag); | |||
| setBackgroundBrush(QColor(QStringLiteral("#dfe5e9"))); | |||
| connect(scene_, &QGraphicsScene::selectionChanged, | |||
| this, &HmiEditorWidget::handleSelectionChanged); | |||
| } | |||
| void HmiEditorWidget::setPageId(const std::string &page_id) | |||
| { | |||
| if (page_id_ == page_id) | |||
| { | |||
| return; | |||
| } | |||
| page_id_ = page_id; | |||
| reloadPage(); | |||
| } | |||
| void HmiEditorWidget::setEditingEnabled(bool enabled) | |||
| { | |||
| editing_enabled_ = enabled; | |||
| setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag); | |||
| updateItemInteractions(); | |||
| } | |||
| void HmiEditorWidget::setRuntimeActive(bool active) | |||
| { | |||
| runtime_active_ = active; | |||
| if (!runtime_active_) | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setRuntimeValue(false, 0, false); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| void HmiEditorWidget::reloadPage() | |||
| { | |||
| scene_->clear(); | |||
| const HmiPage *page = editor_service_.findPage(page_id_); | |||
| if (page == nullptr) | |||
| { | |||
| scene_->setSceneRect({}); | |||
| emit controlSelected({}); | |||
| return; | |||
| } | |||
| scene_->setSceneRect(0, 0, page->width, page->height); | |||
| QGraphicsRectItem *page_border = scene_->addRect( | |||
| scene_->sceneRect(), QPen(QColor(QStringLiteral("#8a98a3")), 1), Qt::white); | |||
| page_border->setZValue(-1); | |||
| page_border->setAcceptedMouseButtons(Qt::NoButton); | |||
| for (const HmiControl &control : page->controls) | |||
| { | |||
| auto *item = new HmiGraphicsItem( | |||
| control, | |||
| page->width, | |||
| page->height, | |||
| [this](const std::string &control_id, const QPointF &position) | |||
| { | |||
| handleControlMoved(control_id, position); | |||
| }, | |||
| [this](const std::string &control_id) | |||
| { | |||
| handleControlActivated(control_id); | |||
| }); | |||
| scene_->addItem(item); | |||
| item->setInteractionEnabled(editing_enabled_); | |||
| } | |||
| fitCurrentPage(); | |||
| refreshRuntimeValues(); | |||
| } | |||
| void HmiEditorWidget::selectControl(const std::string &control_id) | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr && control_item->controlId() == control_id) | |||
| { | |||
| control_item->setSelected(true); | |||
| ensureVisible(control_item); | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| std::string HmiEditorWidget::selectedControlId() const | |||
| { | |||
| const QList<QGraphicsItem *> selected = scene_->selectedItems(); | |||
| for (QGraphicsItem *item : selected) | |||
| { | |||
| const HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr) | |||
| { | |||
| return control_item->controlId(); | |||
| } | |||
| } | |||
| return {}; | |||
| } | |||
| void HmiEditorWidget::refreshRuntimeValues() | |||
| { | |||
| if (!runtime_active_) | |||
| { | |||
| return; | |||
| } | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item == nullptr) | |||
| { | |||
| continue; | |||
| } | |||
| const HmiControl *control = editor_service_.findControl( | |||
| page_id_, control_item->controlId()); | |||
| if (control == nullptr) | |||
| { | |||
| continue; | |||
| } | |||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | |||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | |||
| } | |||
| } | |||
| void HmiEditorWidget::resizeEvent(QResizeEvent *event) | |||
| { | |||
| QGraphicsView::resizeEvent(event); | |||
| fitCurrentPage(); | |||
| } | |||
| void HmiEditorWidget::fitCurrentPage() | |||
| { | |||
| if (scene_->sceneRect().isEmpty()) | |||
| { | |||
| return; | |||
| } | |||
| fitInView( | |||
| scene_->sceneRect().adjusted(-16, -16, 16, 16), | |||
| Qt::KeepAspectRatio); | |||
| } | |||
| void HmiEditorWidget::updateItemInteractions() | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setInteractionEnabled(editing_enabled_); | |||
| } | |||
| } | |||
| } | |||
| void HmiEditorWidget::handleSelectionChanged() | |||
| { | |||
| emit controlSelected(QString::fromStdString(selectedControlId())); | |||
| } | |||
| void HmiEditorWidget::handleControlMoved( | |||
| const std::string &control_id, const QPointF &position) | |||
| { | |||
| const HmiControl *control = editor_service_.findControl(page_id_, control_id); | |||
| if (control == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| HmiRect bounds = control->bounds; | |||
| bounds.x = static_cast<int>(std::lround(position.x())); | |||
| bounds.y = static_cast<int>(std::lround(position.y())); | |||
| const HmiEditorResult result = editor_service_.moveControl( | |||
| page_id_, control_id, bounds); | |||
| if (!result.succeeded) | |||
| { | |||
| emit editorError(QString::fromStdString(result.message)); | |||
| reloadPage(); | |||
| return; | |||
| } | |||
| emit controlChanged(QString::fromStdString(control_id)); | |||
| } | |||
| void HmiEditorWidget::handleControlActivated(const std::string &control_id) | |||
| { | |||
| if (!runtime_active_) | |||
| { | |||
| return; | |||
| } | |||
| const HmiControl *control = editor_service_.findControl(page_id_, control_id); | |||
| if (control == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| HmiRuntimeWriteResult result; | |||
| if (control->type == HmiControlType::Button) | |||
| { | |||
| result = runtime_service_.toggleButton(*control); | |||
| } | |||
| else if (control->type == HmiControlType::NumericInput) | |||
| { | |||
| bool accepted = false; | |||
| const HmiRuntimeReadResult current = runtime_service_.readControl(*control); | |||
| const int initial = current.succeeded ? current.word_value : 0; | |||
| const int value = QInputDialog::getInt( | |||
| this, | |||
| tr("输入数值"), | |||
| QString::fromUtf8(control->text.data(), static_cast<int>(control->text.size())), | |||
| initial, | |||
| std::numeric_limits<std::int16_t>::min(), | |||
| std::numeric_limits<std::int16_t>::max(), | |||
| 1, | |||
| &accepted); | |||
| if (!accepted) | |||
| { | |||
| return; | |||
| } | |||
| result = runtime_service_.writeNumericInput( | |||
| *control, static_cast<std::int16_t>(value)); | |||
| } | |||
| else | |||
| { | |||
| return; | |||
| } | |||
| if (!result.succeeded) | |||
| { | |||
| emit editorError(tr("寄存器操作失败")); | |||
| return; | |||
| } | |||
| refreshRuntimeValues(); | |||
| } | |||
| @@ -0,0 +1,107 @@ | |||
| /** | |||
| * @file hmi_editor_widget.h | |||
| * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 | |||
| * @author suyu | |||
| * @date 2026-08-10 | |||
| */ | |||
| #pragma once | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| class HmiEditorService; | |||
| class HmiRuntimeService; | |||
| class QGraphicsScene; | |||
| class QResizeEvent; | |||
| /** | |||
| * @brief 将 HMI 页面模型投影为可选择和拖动的图形编辑画布 | |||
| * | |||
| * 图元只保存控件标识,所有工程修改和寄存器访问分别委托给服务层 | |||
| */ | |||
| class HmiEditorWidget final : public QGraphicsView | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** | |||
| * @brief 创建 HMI 编辑画布 | |||
| * @param editor_service HMI 编辑服务,调用方必须保证其生命周期覆盖画布 | |||
| * @param runtime_service HMI 运行服务,调用方必须保证其生命周期覆盖画布 | |||
| * @param parent Qt 父对象 | |||
| */ | |||
| explicit HmiEditorWidget( | |||
| HmiEditorService &editor_service, | |||
| HmiRuntimeService &runtime_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); | |||
| /** | |||
| * @brief 使用当前页面模型完全重建场景 | |||
| * | |||
| * 用于工程加载、控件属性更新后同步模型投影 | |||
| */ | |||
| void reloadPage(); | |||
| /** | |||
| * @brief 选中指定控件并滚动到可见区域 | |||
| * @param control_id 待选中控件标识,不存在时不改变当前选择 | |||
| */ | |||
| void selectControl(const std::string &control_id); | |||
| std::string selectedControlId() 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); | |||
| protected: | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| private: | |||
| void fitCurrentPage(); | |||
| void updateItemInteractions(); | |||
| void handleSelectionChanged(); | |||
| void handleControlMoved(const std::string &control_id, const QPointF &position); | |||
| void handleControlActivated(const std::string &control_id); | |||
| HmiEditorService &editor_service_; | |||
| HmiRuntimeService &runtime_service_; | |||
| QGraphicsScene *scene_ = nullptr; | |||
| std::string page_id_; | |||
| bool editing_enabled_ = true; | |||
| bool runtime_active_ = false; | |||
| }; | |||
| @@ -1,25 +1,657 @@ | |||
| /** | |||
| * @file main_window.cpp | |||
| * @brief 实现综合平台编程器的主窗口。 | |||
| * @version 0.1.0 | |||
| * @author suyu | |||
| * @date 2026-08-05 | |||
| */ | |||
| #include "main_window.h" | |||
| #include "hmi_editor_widget.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "ui_main_window.h" | |||
| namespace integrated_platform { | |||
| #include <QActionGroup> | |||
| #include <QApplication> | |||
| #include <QComboBox> | |||
| #include <QFileDialog> | |||
| #include <QFormLayout> | |||
| #include <QInputDialog> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <QMessageBox> | |||
| #include <QPushButton> | |||
| #include <QSpinBox> | |||
| #include <QStatusBar> | |||
| #include <QStyle> | |||
| #include <QTimer> | |||
| #include <QToolBar> | |||
| #include <QTreeWidget> | |||
| #include <QTreeWidgetItem> | |||
| namespace { | |||
| QString modeText(ApplicationMode mode) | |||
| { | |||
| switch (mode) | |||
| { | |||
| case ApplicationMode::Editing: | |||
| { | |||
| return MainWindow::tr("编辑态"); | |||
| } | |||
| case ApplicationMode::OfflineRunning: | |||
| { | |||
| return MainWindow::tr("离线运行态"); | |||
| } | |||
| case ApplicationMode::OnlineRunning: | |||
| { | |||
| return MainWindow::tr("真机运行态"); | |||
| } | |||
| default: | |||
| { | |||
| return MainWindow::tr("未知状态"); | |||
| } | |||
| } | |||
| } | |||
| QString transitionErrorText(ModeTransitionError error) | |||
| { | |||
| switch (error) | |||
| { | |||
| case ModeTransitionError::MustReturnToEditing: | |||
| { | |||
| return MainWindow::tr("请先返回编辑态,再切换运行模式"); | |||
| } | |||
| case ModeTransitionError::InitialPlcReadRequired: | |||
| { | |||
| return MainWindow::tr("真机运行前必须连接 PLC 并完成首次读取"); | |||
| } | |||
| case ModeTransitionError::AlreadyInRequestedMode: | |||
| { | |||
| return MainWindow::tr("当前已处于所选模式"); | |||
| } | |||
| case ModeTransitionError::None: | |||
| default: | |||
| { | |||
| return MainWindow::tr("模式切换失败"); | |||
| } | |||
| } | |||
| } | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| QString controlTypeText(HmiControlType type) | |||
| { | |||
| switch (type) | |||
| { | |||
| case HmiControlType::Button: | |||
| { | |||
| return MainWindow::tr("按钮"); | |||
| } | |||
| case HmiControlType::Indicator: | |||
| { | |||
| return MainWindow::tr("指示灯"); | |||
| } | |||
| case HmiControlType::NumericDisplay: | |||
| { | |||
| return MainWindow::tr("数值显示"); | |||
| } | |||
| case HmiControlType::NumericInput: | |||
| { | |||
| return MainWindow::tr("数值输入"); | |||
| } | |||
| case HmiControlType::Label: | |||
| default: | |||
| { | |||
| return MainWindow::tr("文本"); | |||
| } | |||
| } | |||
| } | |||
| bool usesMAddress(HmiControlType type) | |||
| { | |||
| return type == HmiControlType::Button || type == HmiControlType::Indicator; | |||
| } | |||
| } // namespace | |||
| MainWindow::MainWindow(QWidget *parent) | |||
| MainWindow::MainWindow( | |||
| RuntimeModeService &runtime_mode_service, | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| QWidget *parent) | |||
| : QMainWindow(parent), | |||
| ui_(std::make_unique<Ui::MainWindow>()) | |||
| ui_(std::make_unique<Ui::MainWindow>()), | |||
| runtime_mode_service_(runtime_mode_service), | |||
| project_service_(project_service), | |||
| hmi_editor_service_(hmi_editor_service), | |||
| hmi_runtime_service_(hmi_runtime_service) | |||
| { | |||
| ui_->setupUi(this); | |||
| statusBar()->showMessage(tr("开发基线已建立")); | |||
| configureAppearance(); | |||
| configureActions(); | |||
| configurePropertyEditor(); | |||
| configureHmiEditor(); | |||
| hmi_editor_service_.ensureDefaultPage(); | |||
| refreshProjectUi(); | |||
| updateModeUi(tr("系统已进入编辑态")); | |||
| } | |||
| MainWindow::~MainWindow() = default; | |||
| } // namespace integrated_platform | |||
| void MainWindow::configureActions() | |||
| { | |||
| mode_action_group_ = new QActionGroup(this); | |||
| mode_action_group_->setExclusive(true); | |||
| mode_action_group_->addAction(ui_->editingModeAction); | |||
| mode_action_group_->addAction(ui_->offlineModeAction); | |||
| mode_action_group_->addAction(ui_->onlineModeAction); | |||
| connect(ui_->editingModeAction, &QAction::triggered, this, | |||
| [this] { requestMode(ApplicationMode::Editing); }); | |||
| connect(ui_->offlineModeAction, &QAction::triggered, this, | |||
| [this] { requestMode(ApplicationMode::OfflineRunning); }); | |||
| connect(ui_->onlineModeAction, &QAction::triggered, this, | |||
| [this] { requestMode(ApplicationMode::OnlineRunning); }); | |||
| connect(ui_->exitAction, &QAction::triggered, qApp, &QApplication::closeAllWindows); | |||
| new_project_action_ = new QAction(tr("新建工程"), this); | |||
| save_project_action_ = new QAction(tr("保存工程"), this); | |||
| save_as_project_action_ = new QAction(tr("工程另存为"), this); | |||
| load_project_action_ = new QAction(tr("加载工程"), this); | |||
| new_project_action_->setObjectName(QStringLiteral("newProjectAction")); | |||
| save_project_action_->setObjectName(QStringLiteral("saveProjectAction")); | |||
| save_as_project_action_->setObjectName(QStringLiteral("saveAsProjectAction")); | |||
| load_project_action_->setObjectName(QStringLiteral("loadProjectAction")); | |||
| new_project_action_->setIcon(style()->standardIcon(QStyle::SP_FileIcon)); | |||
| save_project_action_->setIcon(style()->standardIcon(QStyle::SP_DialogSaveButton)); | |||
| load_project_action_->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); | |||
| ui_->fileMenu->insertAction(ui_->exitAction, new_project_action_); | |||
| ui_->fileMenu->insertAction(ui_->exitAction, save_project_action_); | |||
| ui_->fileMenu->insertAction(ui_->exitAction, save_as_project_action_); | |||
| ui_->fileMenu->insertAction(ui_->exitAction, load_project_action_); | |||
| ui_->fileMenu->insertSeparator(ui_->exitAction); | |||
| connect(new_project_action_, &QAction::triggered, this, &MainWindow::createNewProject); | |||
| connect(save_project_action_, &QAction::triggered, this, &MainWindow::saveProject); | |||
| connect(save_as_project_action_, &QAction::triggered, this, &MainWindow::saveProjectAs); | |||
| connect(load_project_action_, &QAction::triggered, this, &MainWindow::loadProject); | |||
| hmi_tool_bar_ = addToolBar(tr("HMI 控件")); | |||
| hmi_tool_bar_->setObjectName(QStringLiteral("hmiToolBar")); | |||
| hmi_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); | |||
| add_button_action_ = hmi_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_DialogApplyButton), tr("按钮")); | |||
| add_indicator_action_ = hmi_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_DialogYesButton), tr("指示灯")); | |||
| add_numeric_display_action_ = hmi_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("数值显示")); | |||
| add_numeric_input_action_ = hmi_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_FileDialogContentsView), tr("数值输入")); | |||
| delete_control_action_ = hmi_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_TrashIcon), tr("删除")); | |||
| add_button_action_->setObjectName(QStringLiteral("addButtonAction")); | |||
| add_indicator_action_->setObjectName(QStringLiteral("addIndicatorAction")); | |||
| add_numeric_display_action_->setObjectName(QStringLiteral("addNumericDisplayAction")); | |||
| add_numeric_input_action_->setObjectName(QStringLiteral("addNumericInputAction")); | |||
| delete_control_action_->setObjectName(QStringLiteral("deleteControlAction")); | |||
| add_button_action_->setToolTip(tr("添加按钮控件")); | |||
| add_indicator_action_->setToolTip(tr("添加指示灯控件")); | |||
| add_numeric_display_action_->setToolTip(tr("添加数值显示控件")); | |||
| add_numeric_input_action_->setToolTip(tr("添加数值输入控件")); | |||
| delete_control_action_->setToolTip(tr("删除当前控件")); | |||
| connect(add_button_action_, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::Button); }); | |||
| connect(add_indicator_action_, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::Indicator); }); | |||
| connect(add_numeric_display_action_, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::NumericDisplay); }); | |||
| connect(add_numeric_input_action_, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::NumericInput); }); | |||
| connect(delete_control_action_, &QAction::triggered, | |||
| this, &MainWindow::deleteSelectedControl); | |||
| ui_->viewMenu->addAction(ui_->projectDock->toggleViewAction()); | |||
| ui_->viewMenu->addAction(ui_->propertiesDock->toggleViewAction()); | |||
| ui_->viewMenu->addAction(ui_->outputDock->toggleViewAction()); | |||
| ui_->viewMenu->addAction(hmi_tool_bar_->toggleViewAction()); | |||
| ui_->viewMenu->addSeparator(); | |||
| ui_->viewMenu->addAction(ui_->modeToolBar->toggleViewAction()); | |||
| } | |||
| void MainWindow::configureAppearance() | |||
| { | |||
| setDockNestingEnabled(true); | |||
| setCorner(Qt::BottomLeftCorner, Qt::LeftDockWidgetArea); | |||
| setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea); | |||
| ui_->editingModeAction->setIcon(style()->standardIcon(QStyle::SP_FileDialogDetailedView)); | |||
| ui_->offlineModeAction->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); | |||
| ui_->onlineModeAction->setIcon(style()->standardIcon(QStyle::SP_DriveNetIcon)); | |||
| ui_->editorTabWidget->setTabIcon(0, style()->standardIcon(QStyle::SP_DesktopIcon)); | |||
| ui_->editorTabWidget->setTabIcon(1, style()->standardIcon(QStyle::SP_FileDialogListView)); | |||
| ui_->outputDock->setMaximumHeight(220); | |||
| mode_status_label_ = new QLabel(this); | |||
| mode_status_label_->setObjectName(QStringLiteral("modeStatusLabel")); | |||
| mode_status_label_->setMinimumWidth(88); | |||
| mode_status_label_->setAlignment(Qt::AlignCenter); | |||
| register_status_label_ = new QLabel(this); | |||
| register_status_label_->setMinimumWidth(132); | |||
| executor_status_label_ = new QLabel(this); | |||
| executor_status_label_->setMinimumWidth(132); | |||
| statusBar()->addPermanentWidget(mode_status_label_); | |||
| statusBar()->addPermanentWidget(register_status_label_); | |||
| statusBar()->addPermanentWidget(executor_status_label_); | |||
| setStyleSheet(QStringLiteral( | |||
| "QMainWindow { background: #f3f5f7; }" | |||
| "QToolBar { background: #ffffff; border: 0; border-bottom: 1px solid #cbd2d9;" | |||
| " padding: 4px; spacing: 3px; }" | |||
| "QToolButton { min-height: 26px; padding: 3px 9px; border: 1px solid transparent; }" | |||
| "QToolButton:hover { background: #edf2f6; border-color: #c5ced6; }" | |||
| "QToolButton:checked { background: #dcece3; border-color: #75a58a; }" | |||
| "QDockWidget { color: #24313b; font-weight: 600; }" | |||
| "QDockWidget::title { background: #e9edf0; padding: 6px; border-bottom: 1px solid #cbd2d9; }" | |||
| "QTreeWidget, QListWidget, QScrollArea { background: #ffffff; border: 1px solid #d5dbe0; }" | |||
| "QTabWidget::pane { border: 0; background: #f3f5f7; }" | |||
| "QTabBar::tab { background: #e5e9ec; padding: 7px 14px; border-right: 1px solid #cbd2d9; }" | |||
| "QTabBar::tab:selected { background: #ffffff; color: #15232d; }" | |||
| "QFrame#hmiCanvasPlaceholder, QFrame#logicCanvasPlaceholder { background: #ffffff;" | |||
| " border: 1px solid #cbd2d9; }" | |||
| "QLabel#hmiEmptyLabel, QLabel#logicEmptyLabel { color: #75838d; }" | |||
| "QLabel#hmiPageTitleLabel { color: #1d2a33; font-weight: 600; }" | |||
| "QLabel#hmiPageSizeLabel { color: #66747e; }")); | |||
| } | |||
| void MainWindow::configureHmiEditor() | |||
| { | |||
| QLayout *layout = ui_->hmiCanvasPlaceholder->layout(); | |||
| delete ui_->hmiEmptyLabel; | |||
| hmi_editor_widget_ = new HmiEditorWidget( | |||
| hmi_editor_service_, hmi_runtime_service_, ui_->hmiCanvasPlaceholder); | |||
| hmi_editor_widget_->setMinimumHeight(320); | |||
| layout->addWidget(hmi_editor_widget_); | |||
| connect(hmi_editor_widget_, &HmiEditorWidget::controlSelected, | |||
| this, | |||
| [this](const QString &id) { showControlProperties(toUtf8(id)); }); | |||
| connect(hmi_editor_widget_, &HmiEditorWidget::controlChanged, | |||
| this, | |||
| [this](const QString &id) | |||
| { | |||
| showControlProperties(toUtf8(id)); | |||
| refreshProjectUi(); | |||
| }); | |||
| 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(); | |||
| } | |||
| }); | |||
| runtime_refresh_timer_->start(); | |||
| } | |||
| void MainWindow::configurePropertyEditor() | |||
| { | |||
| control_id_edit_ = new QLineEdit(ui_->propertiesPage); | |||
| control_id_edit_->setObjectName(QStringLiteral("controlIdEdit")); | |||
| control_text_edit_ = new QLineEdit(ui_->propertiesPage); | |||
| control_text_edit_->setObjectName(QStringLiteral("controlTextEdit")); | |||
| control_x_spin_box_ = new QSpinBox(ui_->propertiesPage); | |||
| control_y_spin_box_ = new QSpinBox(ui_->propertiesPage); | |||
| control_width_spin_box_ = new QSpinBox(ui_->propertiesPage); | |||
| control_height_spin_box_ = new QSpinBox(ui_->propertiesPage); | |||
| control_x_spin_box_->setObjectName(QStringLiteral("controlXSpinBox")); | |||
| control_y_spin_box_->setObjectName(QStringLiteral("controlYSpinBox")); | |||
| control_width_spin_box_->setObjectName(QStringLiteral("controlWidthSpinBox")); | |||
| control_height_spin_box_->setObjectName(QStringLiteral("controlHeightSpinBox")); | |||
| for (QSpinBox *box : {control_x_spin_box_, control_y_spin_box_}) | |||
| { | |||
| box->setRange(0, 4000); | |||
| } | |||
| for (QSpinBox *box : {control_width_spin_box_, control_height_spin_box_}) | |||
| { | |||
| box->setRange(1, 4000); | |||
| } | |||
| binding_area_combo_box_ = new QComboBox(ui_->propertiesPage); | |||
| binding_area_combo_box_->setObjectName(QStringLiteral("bindingAreaComboBox")); | |||
| binding_area_combo_box_->addItem(QStringLiteral("M"), QVariant::fromValue(0)); | |||
| binding_area_combo_box_->addItem(QStringLiteral("D"), QVariant::fromValue(1)); | |||
| binding_index_spin_box_ = new QSpinBox(ui_->propertiesPage); | |||
| binding_index_spin_box_->setObjectName(QStringLiteral("bindingIndexSpinBox")); | |||
| binding_index_spin_box_->setRange(0, RegisterAddress::kMaximumIndex); | |||
| apply_properties_button_ = new QPushButton(tr("应用属性"), ui_->propertiesPage); | |||
| apply_properties_button_->setObjectName(QStringLiteral("applyPropertiesButton")); | |||
| QFormLayout *form = ui_->propertiesForm; | |||
| form->addRow(tr("控件 ID"), control_id_edit_); | |||
| form->addRow(tr("显示文本"), control_text_edit_); | |||
| form->addRow(tr("X"), control_x_spin_box_); | |||
| form->addRow(tr("Y"), control_y_spin_box_); | |||
| form->addRow(tr("宽度"), control_width_spin_box_); | |||
| form->addRow(tr("高度"), control_height_spin_box_); | |||
| form->addRow(tr("绑定区域"), binding_area_combo_box_); | |||
| form->addRow(tr("绑定地址"), binding_index_spin_box_); | |||
| form->addRow(apply_properties_button_); | |||
| connect(apply_properties_button_, &QPushButton::clicked, | |||
| this, &MainWindow::applySelectedControlProperties); | |||
| showControlProperties({}); | |||
| } | |||
| void MainWindow::refreshProjectUi() | |||
| { | |||
| const std::string current_page_id = hmi_editor_service_.firstPageId(); | |||
| if (hmi_editor_widget_ != nullptr) | |||
| { | |||
| hmi_editor_widget_->setPageId(current_page_id); | |||
| } | |||
| const HmiPage *page = hmi_editor_service_.findPage(current_page_id); | |||
| ui_->projectTree->clear(); | |||
| auto *hmi_root = new QTreeWidgetItem(ui_->projectTree, {tr("HMI 页面")}); | |||
| if (page != nullptr) | |||
| { | |||
| hmi_root->addChild(new QTreeWidgetItem(hmi_root, {fromUtf8(page->name)})); | |||
| ui_->hmiPageTitleLabel->setText(fromUtf8(page->name)); | |||
| ui_->hmiPageSizeLabel->setText( | |||
| tr("%1 x %2").arg(page->width).arg(page->height)); | |||
| } | |||
| else | |||
| { | |||
| ui_->hmiPageTitleLabel->setText(tr("尚未创建 HMI 页面")); | |||
| ui_->hmiPageSizeLabel->clear(); | |||
| } | |||
| new QTreeWidgetItem(ui_->projectTree, {tr("控制逻辑")}); | |||
| ui_->projectTree->expandAll(); | |||
| } | |||
| void MainWindow::showControlProperties(const std::string &control_id) | |||
| { | |||
| selected_control_id_ = control_id; | |||
| const HmiControl *control = hmi_editor_service_.findControl( | |||
| hmi_editor_service_.firstPageId(), control_id); | |||
| const bool has_control = control != nullptr; | |||
| ui_->selectionValueLabel->setText( | |||
| has_control ? fromUtf8(control->id) : tr("未选择")); | |||
| for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_), | |||
| static_cast<QWidget *>(control_text_edit_), | |||
| static_cast<QWidget *>(control_x_spin_box_), | |||
| static_cast<QWidget *>(control_y_spin_box_), | |||
| static_cast<QWidget *>(control_width_spin_box_), | |||
| static_cast<QWidget *>(control_height_spin_box_), | |||
| static_cast<QWidget *>(binding_area_combo_box_), | |||
| static_cast<QWidget *>(binding_index_spin_box_), | |||
| static_cast<QWidget *>(apply_properties_button_)}) | |||
| { | |||
| widget->setEnabled(has_control); | |||
| } | |||
| if (!has_control) | |||
| { | |||
| return; | |||
| } | |||
| 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); | |||
| const RegisterArea default_area = usesMAddress(control->type) | |||
| ? RegisterArea::M : RegisterArea::D; | |||
| const RegisterArea area = control->binding.has_value() | |||
| ? control->binding->area() : default_area; | |||
| binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); | |||
| binding_index_spin_box_->setValue( | |||
| control->binding.has_value() ? control->binding->index() : 0); | |||
| } | |||
| void MainWindow::addHmiControl(HmiControlType type) | |||
| { | |||
| const std::string page_id = hmi_editor_service_.firstPageId(); | |||
| const HmiEditorResult result = hmi_editor_service_.addControl(page_id, type); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("添加控件"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| hmi_editor_widget_->reloadPage(); | |||
| hmi_editor_widget_->selectControl(result.id); | |||
| showControlProperties(result.id); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已添加%1").arg(controlTypeText(type)), 3000); | |||
| } | |||
| void MainWindow::deleteSelectedControl() | |||
| { | |||
| if (selected_control_id_.empty()) | |||
| { | |||
| return; | |||
| } | |||
| const HmiEditorResult result = hmi_editor_service_.removeControl( | |||
| hmi_editor_service_.firstPageId(), selected_control_id_); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("删除控件"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_control_id_.clear(); | |||
| hmi_editor_widget_->reloadPage(); | |||
| showControlProperties({}); | |||
| refreshProjectUi(); | |||
| } | |||
| void MainWindow::applySelectedControlProperties() | |||
| { | |||
| const HmiControl *old_control = hmi_editor_service_.findControl( | |||
| hmi_editor_service_.firstPageId(), selected_control_id_); | |||
| if (old_control == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| HmiControl control = *old_control; | |||
| control.id = toUtf8(control_id_edit_->text()); | |||
| control.text = toUtf8(control_text_edit_->text()); | |||
| control.bounds.x = control_x_spin_box_->value(); | |||
| control.bounds.y = control_y_spin_box_->value(); | |||
| control.bounds.width = control_width_spin_box_->value(); | |||
| control.bounds.height = control_height_spin_box_->value(); | |||
| const RegisterArea area = binding_area_combo_box_->currentIndex() == 0 | |||
| ? RegisterArea::M : RegisterArea::D; | |||
| control.binding = RegisterAddress{area, binding_index_spin_box_->value()}; | |||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | |||
| hmi_editor_service_.firstPageId(), selected_control_id_, control); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("应用属性"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_control_id_ = result.id; | |||
| hmi_editor_widget_->reloadPage(); | |||
| hmi_editor_widget_->selectControl(selected_control_id_); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("控件属性已更新"), 3000); | |||
| } | |||
| void MainWindow::createNewProject() | |||
| { | |||
| const QString name = QInputDialog::getText( | |||
| this, tr("新建工程"), tr("工程名称")); | |||
| if (name.isEmpty()) | |||
| { | |||
| return; | |||
| } | |||
| const ProjectOperationResult result = project_service_.createNewProject(toUtf8(name)); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("新建工程"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| hmi_editor_service_.ensureDefaultPage(); | |||
| selected_control_id_.clear(); | |||
| refreshProjectUi(); | |||
| hmi_editor_widget_->reloadPage(); | |||
| showControlProperties({}); | |||
| statusBar()->showMessage(tr("已创建新工程"), 3000); | |||
| } | |||
| void MainWindow::saveProject() | |||
| { | |||
| const ProjectOperationResult result = project_service_.save(); | |||
| if (!result.succeeded) | |||
| { | |||
| saveProjectAs(); | |||
| return; | |||
| } | |||
| showProjectResult(tr("保存工程"), tr("工程已保存"), true); | |||
| } | |||
| void MainWindow::saveProjectAs() | |||
| { | |||
| const QString path = QFileDialog::getSaveFileName( | |||
| this, tr("工程另存为"), {}, tr("工程文件 (*.json)")); | |||
| if (path.isEmpty()) | |||
| { | |||
| return; | |||
| } | |||
| const ProjectOperationResult result = project_service_.saveAs(toUtf8(path)); | |||
| showProjectResult( | |||
| tr("保存工程"), | |||
| result.succeeded ? tr("工程已保存") : fromUtf8(result.message), | |||
| result.succeeded); | |||
| } | |||
| void MainWindow::loadProject() | |||
| { | |||
| const QString path = QFileDialog::getOpenFileName( | |||
| this, tr("加载工程"), {}, tr("工程文件 (*.json)")); | |||
| if (path.isEmpty()) | |||
| { | |||
| return; | |||
| } | |||
| const ProjectOperationResult result = project_service_.load(toUtf8(path)); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("加载工程"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_control_id_.clear(); | |||
| refreshProjectUi(); | |||
| hmi_editor_widget_->reloadPage(); | |||
| showControlProperties({}); | |||
| showProjectResult(tr("加载工程"), tr("工程已加载"), true); | |||
| } | |||
| void MainWindow::showProjectResult( | |||
| const QString &action, const QString &message, bool succeeded) | |||
| { | |||
| const QString output = action + QStringLiteral(": ") + message; | |||
| statusBar()->showMessage(output, 5000); | |||
| ui_->outputList->addItem(output); | |||
| ui_->outputList->scrollToBottom(); | |||
| if (!succeeded) | |||
| { | |||
| QMessageBox::warning(this, action, message); | |||
| } | |||
| } | |||
| void MainWindow::requestMode(ApplicationMode requested_mode) | |||
| { | |||
| ModeTransitionResult result; | |||
| switch (requested_mode) | |||
| { | |||
| case ApplicationMode::Editing: | |||
| { | |||
| result = runtime_mode_service_.enterEditing(); | |||
| break; | |||
| } | |||
| case ApplicationMode::OfflineRunning: | |||
| { | |||
| result = runtime_mode_service_.enterOfflineRunning(); | |||
| break; | |||
| } | |||
| case ApplicationMode::OnlineRunning: | |||
| { | |||
| result = runtime_mode_service_.enterOnlineRunning(); | |||
| break; | |||
| } | |||
| default: | |||
| { | |||
| restoreCurrentModeAction(); | |||
| statusBar()->showMessage(tr("不支持的运行模式"), 5000); | |||
| return; | |||
| } | |||
| } | |||
| if (!result.succeeded) | |||
| { | |||
| restoreCurrentModeAction(); | |||
| statusBar()->showMessage(transitionErrorText(result.error), 5000); | |||
| return; | |||
| } | |||
| updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode()))); | |||
| } | |||
| 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); | |||
| hmi_editor_widget_->setRuntimeActive(policy.usesVirtualRegisters); | |||
| hmi_tool_bar_->setEnabled(policy.allowsProjectEditing); | |||
| add_button_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_indicator_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_numeric_display_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_numeric_input_action_->setEnabled(policy.allowsProjectEditing); | |||
| delete_control_action_->setEnabled(policy.allowsProjectEditing); | |||
| new_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| save_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| save_as_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| load_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| mode_status_label_->setText(modeText(mode)); | |||
| if (mode == ApplicationMode::Editing) | |||
| { | |||
| mode_status_label_->setStyleSheet(QStringLiteral( | |||
| "border: 1px solid #75a58a; background: #e3f0e8; color: #205c3b; padding: 2px 8px;")); | |||
| } | |||
| else if (mode == ApplicationMode::OfflineRunning) | |||
| { | |||
| mode_status_label_->setStyleSheet(QStringLiteral( | |||
| "border: 1px solid #bd8739; background: #fff0d2; color: #744b0e; padding: 2px 8px;")); | |||
| } | |||
| else | |||
| { | |||
| mode_status_label_->setStyleSheet(QStringLiteral( | |||
| "border: 1px solid #4b88a8; background: #deeff7; color: #174f6c; padding: 2px 8px;")); | |||
| } | |||
| register_status_label_->setText( | |||
| policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D") | |||
| : policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用")); | |||
| executor_status_label_->setText( | |||
| policy.runsLogicExecutor ? tr("逻辑执行器:运行") : tr("逻辑执行器:停止")); | |||
| statusBar()->showMessage(message, 4000); | |||
| ui_->outputList->addItem(message); | |||
| ui_->outputList->scrollToBottom(); | |||
| } | |||
| void MainWindow::restoreCurrentModeAction() | |||
| { | |||
| ui_->editingModeAction->setChecked(runtime_mode_service_.mode() | |||
| == ApplicationMode::Editing); | |||
| ui_->offlineModeAction->setChecked(runtime_mode_service_.mode() | |||
| == ApplicationMode::OfflineRunning); | |||
| ui_->onlineModeAction->setChecked(runtime_mode_service_.mode() | |||
| == ApplicationMode::OnlineRunning); | |||
| } | |||
| @@ -1,36 +1,139 @@ | |||
| /** | |||
| * @file main_window.h | |||
| * @brief 定义综合平台编程器的主窗口。 | |||
| * @version 0.1.0 | |||
| * @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑 | |||
| * @version 0.2.0 | |||
| * @author suyu | |||
| * @date 2026-08-05 | |||
| * @date 2026-08-08 | |||
| */ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include "domain/runtime_state.h" | |||
| #include <QMainWindow> | |||
| #include <memory> | |||
| QT_BEGIN_NAMESPACE | |||
| class QActionGroup; | |||
| class QAction; | |||
| class QComboBox; | |||
| class QLabel; | |||
| class QLineEdit; | |||
| class QPushButton; | |||
| class QSpinBox; | |||
| class QTimer; | |||
| class QToolBar; | |||
| namespace Ui { | |||
| class MainWindow; | |||
| } | |||
| QT_END_NAMESPACE | |||
| namespace integrated_platform { | |||
| class RuntimeModeService; | |||
| class ProjectService; | |||
| class HmiEditorService; | |||
| class HmiRuntimeService; | |||
| class HmiEditorWidget; | |||
| /** | |||
| * @brief 组织主界面区域并将用户操作转交给服务层 | |||
| * | |||
| * 主窗口不直接访问寄存器、工程文件或 PLC 通信实现 | |||
| */ | |||
| class MainWindow final : public QMainWindow | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| explicit MainWindow(QWidget *parent = nullptr); | |||
| /** | |||
| * @brief 创建主窗口并注入 UI 所需的应用服务 | |||
| * @param runtime_mode_service 运行模式服务,调用方必须保证其生命周期覆盖窗口 | |||
| * @param project_service 工程管理服务,调用方必须保证其生命周期覆盖窗口 | |||
| * @param hmi_editor_service HMI 编辑服务,调用方必须保证其生命周期覆盖窗口 | |||
| * @param hmi_runtime_service HMI 运行服务,调用方必须保证其生命周期覆盖窗口 | |||
| * @param parent Qt 父对象 | |||
| */ | |||
| explicit MainWindow( | |||
| RuntimeModeService &runtime_mode_service, | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| QWidget *parent = nullptr); | |||
| ~MainWindow() override; | |||
| private: | |||
| void configureActions(); | |||
| void configureAppearance(); | |||
| void configureHmiEditor(); | |||
| void configurePropertyEditor(); | |||
| void refreshProjectUi(); | |||
| void showControlProperties(const std::string &control_id); | |||
| void addHmiControl(HmiControlType type); | |||
| void deleteSelectedControl(); | |||
| void applySelectedControlProperties(); | |||
| void createNewProject(); | |||
| void saveProject(); | |||
| void saveProjectAs(); | |||
| void loadProject(); | |||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | |||
| /** | |||
| * @brief 请求服务层切换模式并同步界面状态 | |||
| * @param requested_mode 目标应用模式 | |||
| * | |||
| * 失败时恢复与服务层当前状态一致的工具栏选项 | |||
| */ | |||
| void requestMode(ApplicationMode requested_mode); | |||
| /** | |||
| * @brief 根据服务层模式策略更新编辑权限和状态反馈 | |||
| * @param message 要显示在状态栏和输出区的模式结果消息 | |||
| */ | |||
| void updateModeUi(const QString &message); | |||
| /** | |||
| * @brief 将互斥模式动作同步到服务层当前状态 | |||
| */ | |||
| void restoreCurrentModeAction(); | |||
| std::unique_ptr<Ui::MainWindow> ui_; | |||
| /** | |||
| * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| RuntimeModeService &runtime_mode_service_; | |||
| /** | |||
| * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| ProjectService &project_service_; | |||
| HmiEditorService &hmi_editor_service_; | |||
| HmiRuntimeService &hmi_runtime_service_; | |||
| QActionGroup *mode_action_group_ = nullptr; | |||
| QAction *new_project_action_ = nullptr; | |||
| QAction *save_project_action_ = nullptr; | |||
| QAction *save_as_project_action_ = nullptr; | |||
| QAction *load_project_action_ = nullptr; | |||
| QAction *add_button_action_ = nullptr; | |||
| QAction *add_indicator_action_ = nullptr; | |||
| QAction *add_numeric_display_action_ = nullptr; | |||
| QAction *add_numeric_input_action_ = nullptr; | |||
| QAction *delete_control_action_ = nullptr; | |||
| QToolBar *hmi_tool_bar_ = nullptr; | |||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | |||
| QTimer *runtime_refresh_timer_ = nullptr; | |||
| QLabel *mode_status_label_ = nullptr; | |||
| QLabel *register_status_label_ = nullptr; | |||
| QLabel *executor_status_label_ = nullptr; | |||
| QLineEdit *control_id_edit_ = nullptr; | |||
| QLineEdit *control_text_edit_ = nullptr; | |||
| QSpinBox *control_x_spin_box_ = nullptr; | |||
| QSpinBox *control_y_spin_box_ = nullptr; | |||
| QSpinBox *control_width_spin_box_ = nullptr; | |||
| QSpinBox *control_height_spin_box_ = nullptr; | |||
| QComboBox *binding_area_combo_box_ = nullptr; | |||
| QSpinBox *binding_index_spin_box_ = nullptr; | |||
| QPushButton *apply_properties_button_ = nullptr; | |||
| std::string selected_control_id_; | |||
| }; | |||
| } // namespace integrated_platform | |||
| @@ -6,72 +6,466 @@ | |||
| <rect> | |||
| <x>0</x> | |||
| <y>0</y> | |||
| <width>1280</width> | |||
| <height>800</height> | |||
| </rect> | |||
| </property> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| <width>960</width> | |||
| <height>640</height> | |||
| </rect> | |||
| </size> | |||
| </property> | |||
| <property name="windowTitle"> | |||
| <property name="windowTitle"> | |||
| <string>综合平台编程器</string> | |||
| </property> | |||
| <property name="dockOptions"> | |||
| <set>QMainWindow::AllowNestedDocks|QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks</set> | |||
| </property> | |||
| <widget class="QWidget" name="centralWidget"> | |||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||
| <layout class="QVBoxLayout" name="centralLayout"> | |||
| <property name="spacing"> | |||
| <number>0</number> | |||
| </property> | |||
| <property name="leftMargin"> | |||
| <number>0</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>0</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>0</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>0</number> | |||
| </property> | |||
| <item> | |||
| <spacer name="topSpacer"> | |||
| <property name="orientation"> | |||
| <enum>Qt::Vertical</enum> | |||
| </property> | |||
| <property name="sizeHint" stdset="0"> | |||
| <size> | |||
| <width>20</width> | |||
| <height>180</height> | |||
| </size> | |||
| <widget class="QTabWidget" name="editorTabWidget"> | |||
| <property name="documentMode"> | |||
| <bool>true</bool> | |||
| </property> | |||
| </spacer> | |||
| </item> | |||
| <item> | |||
| <widget class="QLabel" name="titleLabel"> | |||
| <property name="font"> | |||
| <font> | |||
| <pointsize>20</pointsize> | |||
| <bold>true</bold> | |||
| </font> | |||
| </property> | |||
| <property name="text"> | |||
| <string>综合平台编程器</string> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignCenter</set> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <widget class="QLabel" name="statusLabel"> | |||
| <property name="text"> | |||
| <string>Qt Widgets 开发基线已建立</string> | |||
| <property name="tabsClosable"> | |||
| <bool>false</bool> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignCenter</set> | |||
| <property name="movable"> | |||
| <bool>false</bool> | |||
| </property> | |||
| <widget class="QWidget" name="hmiEditorTab"> | |||
| <attribute name="title"> | |||
| <string>HMI 页面</string> | |||
| </attribute> | |||
| <layout class="QVBoxLayout" name="hmiEditorLayout"> | |||
| <property name="spacing"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="leftMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <item> | |||
| <widget class="QFrame" name="hmiPageHeader"> | |||
| <property name="sizePolicy"> | |||
| <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> | |||
| <horstretch>0</horstretch> | |||
| <verstretch>0</verstretch> | |||
| </sizepolicy> | |||
| </property> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| <width>0</width> | |||
| <height>28</height> | |||
| </size> | |||
| </property> | |||
| <property name="maximumSize"> | |||
| <size> | |||
| <width>16777215</width> | |||
| <height>28</height> | |||
| </size> | |||
| </property> | |||
| <property name="frameShape"> | |||
| <enum>QFrame::NoFrame</enum> | |||
| </property> | |||
| <layout class="QHBoxLayout" name="hmiPageHeaderLayout"> | |||
| <property name="leftMargin"> | |||
| <number>2</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>0</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>2</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>0</number> | |||
| </property> | |||
| <item> | |||
| <widget class="QLabel" name="hmiPageTitleLabel"> | |||
| <property name="text"> | |||
| <string>主操作页面</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <spacer name="hmiHeaderSpacer"> | |||
| <property name="orientation"> | |||
| <enum>Qt::Horizontal</enum> | |||
| </property> | |||
| <property name="sizeHint" stdset="0"> | |||
| <size> | |||
| <width>40</width> | |||
| <height>20</height> | |||
| </size> | |||
| </property> | |||
| </spacer> | |||
| </item> | |||
| <item> | |||
| <widget class="QLabel" name="hmiPageSizeLabel"> | |||
| <property name="text"> | |||
| <string>800 x 480</string> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <widget class="QFrame" name="hmiCanvasPlaceholder"> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| <width>0</width> | |||
| <height>320</height> | |||
| </size> | |||
| </property> | |||
| <property name="frameShape"> | |||
| <enum>QFrame::StyledPanel</enum> | |||
| </property> | |||
| <layout class="QVBoxLayout" name="hmiCanvasLayout"> | |||
| <item> | |||
| <widget class="QLabel" name="hmiEmptyLabel"> | |||
| <property name="text"> | |||
| <string>尚未创建 HMI 页面</string> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignCenter</set> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| <widget class="QWidget" name="logicEditorTab"> | |||
| <attribute name="title"> | |||
| <string>控制逻辑</string> | |||
| </attribute> | |||
| <layout class="QVBoxLayout" name="logicEditorLayout"> | |||
| <property name="leftMargin"> | |||
| <number>12</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>12</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>12</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>12</number> | |||
| </property> | |||
| <item> | |||
| <widget class="QFrame" name="logicCanvasPlaceholder"> | |||
| <property name="frameShape"> | |||
| <enum>QFrame::StyledPanel</enum> | |||
| </property> | |||
| <layout class="QVBoxLayout" name="logicCanvasLayout"> | |||
| <item> | |||
| <widget class="QLabel" name="logicEmptyLabel"> | |||
| <property name="text"> | |||
| <string>尚未创建控制逻辑</string> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignCenter</set> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <spacer name="bottomSpacer"> | |||
| <property name="orientation"> | |||
| <enum>Qt::Vertical</enum> | |||
| </property> | |||
| <property name="sizeHint" stdset="0"> | |||
| <size> | |||
| <width>20</width> | |||
| <height>180</height> | |||
| </size> | |||
| </property> | |||
| </spacer> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| <widget class="QMenuBar" name="menuBar"/> | |||
| <widget class="QMenuBar" name="menuBar"> | |||
| <property name="geometry"> | |||
| <rect> | |||
| <x>0</x> | |||
| <y>0</y> | |||
| <width>1280</width> | |||
| <height>23</height> | |||
| </rect> | |||
| </property> | |||
| <widget class="QMenu" name="fileMenu"> | |||
| <property name="title"> | |||
| <string>文件(&F)</string> | |||
| </property> | |||
| <addaction name="exitAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="runMenu"> | |||
| <property name="title"> | |||
| <string>运行(&R)</string> | |||
| </property> | |||
| <addaction name="editingModeAction"/> | |||
| <addaction name="offlineModeAction"/> | |||
| <addaction name="onlineModeAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="viewMenu"> | |||
| <property name="title"> | |||
| <string>视图(&V)</string> | |||
| </property> | |||
| </widget> | |||
| <addaction name="fileMenu"/> | |||
| <addaction name="runMenu"/> | |||
| <addaction name="viewMenu"/> | |||
| </widget> | |||
| <widget class="QStatusBar" name="statusBar"/> | |||
| <widget class="QToolBar" name="modeToolBar"> | |||
| <property name="windowTitle"> | |||
| <string>运行模式</string> | |||
| </property> | |||
| <property name="movable"> | |||
| <bool>false</bool> | |||
| </property> | |||
| <property name="toolButtonStyle"> | |||
| <enum>Qt::ToolButtonTextBesideIcon</enum> | |||
| </property> | |||
| <attribute name="toolBarArea"> | |||
| <enum>TopToolBarArea</enum> | |||
| </attribute> | |||
| <attribute name="toolBarBreak"> | |||
| <bool>false</bool> | |||
| </attribute> | |||
| <addaction name="editingModeAction"/> | |||
| <addaction name="offlineModeAction"/> | |||
| <addaction name="onlineModeAction"/> | |||
| </widget> | |||
| <widget class="QDockWidget" name="projectDock"> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| <width>210</width> | |||
| <height>180</height> | |||
| </size> | |||
| </property> | |||
| <property name="windowTitle"> | |||
| <string>工程</string> | |||
| </property> | |||
| <attribute name="dockWidgetArea"> | |||
| <number>1</number> | |||
| </attribute> | |||
| <widget class="QWidget" name="projectDockContents"> | |||
| <layout class="QVBoxLayout" name="projectDockLayout"> | |||
| <property name="leftMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <item> | |||
| <widget class="QTreeWidget" name="projectTree"> | |||
| <property name="headerHidden"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="rootIsDecorated"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <column> | |||
| <property name="text"> | |||
| <string notr="true">1</string> | |||
| </property> | |||
| </column> | |||
| <item> | |||
| <property name="text"> | |||
| <string>HMI 页面</string> | |||
| </property> | |||
| </item> | |||
| <item> | |||
| <property name="text"> | |||
| <string>控制逻辑</string> | |||
| </property> | |||
| </item> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </widget> | |||
| <widget class="QDockWidget" name="propertiesDock"> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| <width>250</width> | |||
| <height>180</height> | |||
| </size> | |||
| </property> | |||
| <property name="windowTitle"> | |||
| <string>属性</string> | |||
| </property> | |||
| <attribute name="dockWidgetArea"> | |||
| <number>2</number> | |||
| </attribute> | |||
| <widget class="QWidget" name="propertiesDockContents"> | |||
| <layout class="QVBoxLayout" name="propertiesDockLayout"> | |||
| <property name="leftMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <item> | |||
| <widget class="QScrollArea" name="propertiesScrollArea"> | |||
| <property name="frameShape"> | |||
| <enum>QFrame::NoFrame</enum> | |||
| </property> | |||
| <property name="widgetResizable"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <widget class="QWidget" name="propertiesPage"> | |||
| <property name="geometry"> | |||
| <rect> | |||
| <x>0</x> | |||
| <y>0</y> | |||
| <width>232</width> | |||
| <height>164</height> | |||
| </rect> | |||
| </property> | |||
| <layout class="QFormLayout" name="propertiesForm"> | |||
| <property name="fieldGrowthPolicy"> | |||
| <enum>QFormLayout::AllNonFixedFieldsGrow</enum> | |||
| </property> | |||
| <item row="0" column="0"> | |||
| <widget class="QLabel" name="selectionCaptionLabel"> | |||
| <property name="text"> | |||
| <string>当前对象</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="0" column="1"> | |||
| <widget class="QLabel" name="selectionValueLabel"> | |||
| <property name="text"> | |||
| <string>未选择</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </widget> | |||
| <widget class="QDockWidget" name="outputDock"> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| <width>300</width> | |||
| <height>110</height> | |||
| </size> | |||
| </property> | |||
| <property name="windowTitle"> | |||
| <string>输出</string> | |||
| </property> | |||
| <attribute name="dockWidgetArea"> | |||
| <number>8</number> | |||
| </attribute> | |||
| <widget class="QWidget" name="outputDockContents"> | |||
| <layout class="QVBoxLayout" name="outputDockLayout"> | |||
| <property name="leftMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>6</number> | |||
| </property> | |||
| <item> | |||
| <widget class="QListWidget" name="outputList"> | |||
| <item> | |||
| <property name="text"> | |||
| <string>系统已就绪</string> | |||
| </property> | |||
| </item> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </widget> | |||
| <action name="exitAction"> | |||
| <property name="text"> | |||
| <string>退出(&X)</string> | |||
| </property> | |||
| </action> | |||
| <action name="editingModeAction"> | |||
| <property name="checkable"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="checked"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="text"> | |||
| <string>编辑</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>进入编辑态</string> | |||
| </property> | |||
| </action> | |||
| <action name="offlineModeAction"> | |||
| <property name="checkable"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="text"> | |||
| <string>离线运行</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>进入离线运行态</string> | |||
| </property> | |||
| </action> | |||
| <action name="onlineModeAction"> | |||
| <property name="checkable"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="text"> | |||
| <string>真机运行</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>进入真机运行态</string> | |||
| </property> | |||
| </action> | |||
| </widget> | |||
| <resources/> | |||
| <connections/> | |||
| </ui> | |||
| </ui> | |||
| @@ -11,8 +11,6 @@ | |||
| #include <stdexcept> | |||
| #include <string> | |||
| namespace domain = integrated_platform::domain; | |||
| namespace { | |||
| void require(bool condition, const std::string &message) | |||
| @@ -25,67 +23,67 @@ void require(bool condition, const std::string &message) | |||
| void testRegisterAddressBoundaries() | |||
| { | |||
| require(domain::RegisterAddress{domain::RegisterArea::M, 0}.isValid(), | |||
| require(RegisterAddress{RegisterArea::M, 0}.isValid(), | |||
| "M0 must be valid"); | |||
| require(domain::RegisterAddress{domain::RegisterArea::D, 4000}.isValid(), | |||
| require(RegisterAddress{RegisterArea::D, 4000}.isValid(), | |||
| "D4000 must be valid"); | |||
| require(!(domain::RegisterAddress{domain::RegisterArea::M, -1}.isValid()), | |||
| require(!(RegisterAddress{RegisterArea::M, -1}.isValid()), | |||
| "negative register index must be rejected"); | |||
| require(!(domain::RegisterAddress{domain::RegisterArea::D, 4001}.isValid()), | |||
| require(!(RegisterAddress{RegisterArea::D, 4001}.isValid()), | |||
| "register index above 4000 must be rejected"); | |||
| require(!(domain::RegisterAddress{static_cast<domain::RegisterArea>(99), 0}.isValid()), | |||
| require(!(RegisterAddress{static_cast<RegisterArea>(99), 0}.isValid()), | |||
| "unknown register area must be rejected"); | |||
| } | |||
| void testRegisterRepositorySeparatesAreas() | |||
| { | |||
| domain::VirtualRegisterRepository repository; | |||
| const domain::RegisterAddress m0{domain::RegisterArea::M, 0}; | |||
| const domain::RegisterAddress d0{domain::RegisterArea::D, 0}; | |||
| VirtualRegisterRepository repository; | |||
| const RegisterAddress m0{RegisterArea::M, 0}; | |||
| const RegisterAddress d0{RegisterArea::D, 0}; | |||
| require(repository.writeBit(m0, true).succeeded, "M bit write must succeed"); | |||
| require(repository.readBit(m0).value, "M bit read must return written value"); | |||
| require(repository.writeWord(d0, static_cast<std::int16_t>(-123)).succeeded, | |||
| "D word write must succeed"); | |||
| require(repository.readWord(d0).value == -123, "D word read must return written value"); | |||
| require(repository.readBit(d0).error == domain::RegisterError::AreaMismatch, | |||
| require(repository.readBit(d0).error == RegisterError::AreaMismatch, | |||
| "D address must not be read as a bit"); | |||
| require(repository.readWord(m0).error == domain::RegisterError::AreaMismatch, | |||
| require(repository.readWord(m0).error == RegisterError::AreaMismatch, | |||
| "M address must not be read as a word"); | |||
| } | |||
| domain::Project makeValidProject() | |||
| Project makeValidProject() | |||
| { | |||
| domain::HmiControl start_button; | |||
| HmiControl start_button; | |||
| start_button.id = "start-button"; | |||
| start_button.type = domain::HmiControlType::Button; | |||
| start_button.type = HmiControlType::Button; | |||
| start_button.text = "Start"; | |||
| start_button.binding = {domain::RegisterArea::M, 0}; | |||
| start_button.binding = {RegisterArea::M, 0}; | |||
| domain::HmiPage page; | |||
| HmiPage page; | |||
| page.id = "main-page"; | |||
| page.name = "Main"; | |||
| page.controls.push_back(start_button); | |||
| domain::LogicNode contact; | |||
| LogicNode contact; | |||
| contact.id = "start-contact"; | |||
| contact.config = domain::ContactNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::M, 0}, | |||
| domain::ContactMode::NormallyOpen}; | |||
| contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| domain::LogicNode coil; | |||
| LogicNode coil; | |||
| coil.id = "run-coil"; | |||
| coil.config = domain::CoilNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::M, 1}, | |||
| domain::CoilMode::Normal}; | |||
| coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Normal}; | |||
| domain::ControlLogic logic; | |||
| ControlLogic logic; | |||
| logic.id = "start-logic"; | |||
| logic.name = "Start logic"; | |||
| logic.nodes = {contact, coil}; | |||
| logic.connections.push_back({contact.id, coil.id}); | |||
| domain::Project project; | |||
| Project project; | |||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.controlLogics.push_back(logic); | |||
| @@ -94,55 +92,67 @@ domain::Project makeValidProject() | |||
| void testLogicNodeConfigurationBoundaries() | |||
| { | |||
| domain::LogicNode contact; | |||
| LogicNode contact; | |||
| contact.id = "contact"; | |||
| contact.config = domain::ContactNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::M, 0}, | |||
| domain::ContactMode::NormallyOpen}; | |||
| contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| require(contact.validate(), "contact node bound to M address must be valid"); | |||
| contact.config = domain::ContactNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::D, 0}, | |||
| domain::ContactMode::NormallyOpen}; | |||
| contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| require(!contact.validate(), "contact node bound to D address must be rejected"); | |||
| domain::LogicNode comparison; | |||
| LogicNode comparison; | |||
| comparison.id = "comparison"; | |||
| comparison.config = domain::CompareNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::D, 0}, | |||
| domain::ComparisonOperator::GreaterThan, | |||
| comparison.config = CompareNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, 0}, | |||
| ComparisonOperator::GreaterThan, | |||
| static_cast<std::int16_t>(100)}; | |||
| require(comparison.validate(), "comparison node bound to D address must be valid"); | |||
| } | |||
| void testModelsValidateBindingsAndIdentifiers() | |||
| { | |||
| domain::Project project = makeValidProject(); | |||
| Project project = makeValidProject(); | |||
| require(project.validate(), "valid project model must pass validation"); | |||
| project.hmiPages.front().controls.front().binding = | |||
| domain::RegisterAddress{domain::RegisterArea::D, 0}; | |||
| RegisterAddress{RegisterArea::D, 0}; | |||
| require(!project.validate(), "button bound to D area must be rejected"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.push_back(project.hmiPages.front()); | |||
| require(!project.validate(), "duplicate HMI page id must be rejected"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().controls.front().bounds.x = -1; | |||
| require(!project.validate(), "controls outside the page must be rejected"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().controls.front().bounds.width = 801; | |||
| require(!project.validate(), "controls wider than the page must be rejected"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().controls.front().properties.emplace("", "value"); | |||
| require(!project.validate(), "empty HMI property names must be rejected"); | |||
| } | |||
| void testRuntimeStateBoundaries() | |||
| { | |||
| domain::RuntimeState state; | |||
| RuntimeState state; | |||
| require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); | |||
| require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); | |||
| require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers"); | |||
| require(state.policy().runsLogicExecutor, "offline mode must run logic executor"); | |||
| require(state.enterOnlineRunning(true).error | |||
| == domain::ModeTransitionError::MustReturnToEditing, | |||
| == ModeTransitionError::MustReturnToEditing, | |||
| "offline mode must not directly enter online mode"); | |||
| require(state.enterEditing().succeeded, "offline mode may return to editing"); | |||
| require(state.enterOnlineRunning(false).error | |||
| == domain::ModeTransitionError::InitialPlcReadRequired, | |||
| == ModeTransitionError::InitialPlcReadRequired, | |||
| "online mode must require an initial PLC read"); | |||
| require(state.enterOnlineRunning(true).succeeded, | |||
| "editing may enter online mode after initial PLC read"); | |||
| @@ -0,0 +1,137 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/project_service.h" | |||
| #include <exception> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| namespace { | |||
| class TestProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| ProjectSaveResult save(const Project &, const std::string &) override | |||
| { | |||
| return {true, ProjectStorageError::None, {}}; | |||
| } | |||
| ProjectLoadResult load(const std::string &) override | |||
| { | |||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||
| } | |||
| }; | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| void testControlEditing() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService service(project_service); | |||
| const HmiEditorResult page_result = service.ensureDefaultPage(); | |||
| require(page_result.succeeded, "default HMI page creation must succeed"); | |||
| const std::string page_id = page_result.id; | |||
| const HmiEditorResult button = service.addControl(page_id, HmiControlType::Button); | |||
| const HmiEditorResult indicator = service.addControl(page_id, HmiControlType::Indicator); | |||
| const HmiEditorResult display = service.addControl( | |||
| page_id, HmiControlType::NumericDisplay); | |||
| const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput); | |||
| require(button.succeeded && indicator.succeeded && display.succeeded && input.succeeded, | |||
| "four basic HMI controls must be added"); | |||
| const HmiPage *page = service.findPage(page_id); | |||
| require(page != nullptr && page->controls.size() == 4, | |||
| "all added controls must be kept in the page model"); | |||
| require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded, | |||
| "a valid control move must succeed"); | |||
| require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded, | |||
| "a move outside the page must be rejected"); | |||
| HmiControl updated = *service.findControl(page_id, button.id); | |||
| updated.binding = RegisterAddress{RegisterArea::D, 0}; | |||
| require(!service.updateControl(page_id, button.id, updated).succeeded, | |||
| "buttons must reject D address bindings during editing"); | |||
| updated.binding = RegisterAddress{RegisterArea::M, 7}; | |||
| updated.text = "启动"; | |||
| require(service.updateControl(page_id, button.id, updated).succeeded, | |||
| "a button M binding and edited text must be accepted"); | |||
| HmiControl duplicate = *service.findControl(page_id, indicator.id); | |||
| duplicate.id = button.id; | |||
| require(service.updateControl(page_id, indicator.id, duplicate).error | |||
| == HmiEditorError::DuplicateId, | |||
| "duplicate control ids must be rejected"); | |||
| require(service.removeControl(page_id, indicator.id).succeeded, | |||
| "deleting a selected control must succeed"); | |||
| require(service.findControl(page_id, indicator.id) == nullptr, | |||
| "deleted controls must not remain in the model"); | |||
| } | |||
| void testRuntimeUsesRegisterRepository() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| HmiRuntimeService runtime_service(repository); | |||
| HmiControl button; | |||
| button.id = "start"; | |||
| button.type = HmiControlType::Button; | |||
| button.binding = RegisterAddress{RegisterArea::M, 12}; | |||
| require(runtime_service.toggleButton(button).succeeded, | |||
| "button runtime writes must be accepted by the repository"); | |||
| require(repository.readBit(*button.binding).value, | |||
| "button runtime writes must reach the M repository value"); | |||
| HmiControl indicator; | |||
| indicator.id = "running"; | |||
| indicator.type = HmiControlType::Indicator; | |||
| indicator.binding = RegisterAddress{RegisterArea::M, 12}; | |||
| const HmiRuntimeReadResult indicator_value = runtime_service.readControl(indicator); | |||
| require(indicator_value.succeeded && indicator_value.bit_value, | |||
| "indicators must read M values through the repository"); | |||
| HmiControl numeric_input; | |||
| numeric_input.id = "target"; | |||
| numeric_input.type = HmiControlType::NumericInput; | |||
| numeric_input.binding = RegisterAddress{RegisterArea::D, 9}; | |||
| require(runtime_service.writeNumericInput(numeric_input, -18).succeeded, | |||
| "numeric input runtime writes must be accepted by the repository"); | |||
| HmiControl numeric_display; | |||
| numeric_display.id = "actual"; | |||
| numeric_display.type = HmiControlType::NumericDisplay; | |||
| numeric_display.binding = RegisterAddress{RegisterArea::D, 9}; | |||
| const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display); | |||
| require(numeric_value.succeeded && numeric_value.word_value == -18, | |||
| "numeric display must read D values through the repository"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| { | |||
| try | |||
| { | |||
| testControlEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| std::cerr << "HMI editor service tests failed: " << error.what() << '\n'; | |||
| return 1; | |||
| } | |||
| std::cout << "HMI editor service tests passed\n"; | |||
| return 0; | |||
| } | |||
| @@ -0,0 +1,29 @@ | |||
| TEMPLATE = app | |||
| TARGET = hmi_editor_service_tests | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| SOURCES += \ | |||
| hmi_editor_service_tests.cpp \ | |||
| ../src/domain/register_address.cpp \ | |||
| ../src/domain/register_repository.cpp \ | |||
| ../src/domain/hmi_model.cpp \ | |||
| ../src/domain/control_logic_model.cpp \ | |||
| ../src/domain/project_model.cpp \ | |||
| ../src/services/project_service.cpp \ | |||
| ../src/services/hmi_editor_service.cpp \ | |||
| ../src/services/hmi_runtime_service.cpp | |||
| HEADERS += \ | |||
| ../src/domain/register_address.h \ | |||
| ../src/domain/register_repository.h \ | |||
| ../src/domain/hmi_model.h \ | |||
| ../src/domain/control_logic_model.h \ | |||
| ../src/domain/project_model.h \ | |||
| ../src/domain/project_storage.h \ | |||
| ../src/services/project_service.h \ | |||
| ../src/services/hmi_editor_service.h \ | |||
| ../src/services/hmi_runtime_service.h | |||
| @@ -0,0 +1,152 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "ui/hmi_editor_widget.h" | |||
| #include "ui/main_window.h" | |||
| #include <QAction> | |||
| #include <QApplication> | |||
| #include <QDockWidget> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| namespace { | |||
| class TestProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| ProjectSaveResult save(const Project &, const std::string &) override | |||
| { | |||
| return {true, ProjectStorageError::None, {}}; | |||
| } | |||
| ProjectLoadResult load(const std::string &) override | |||
| { | |||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||
| } | |||
| }; | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| template<typename ObjectType> | |||
| ObjectType *requiredChild(MainWindow &window, const char *name) | |||
| { | |||
| ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name)); | |||
| require(child != nullptr, std::string("missing UI object ") + name); | |||
| return child; | |||
| } | |||
| void testModeActionsControlEditingAvailability() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService editor_service(project_service); | |||
| VirtualRegisterRepository repository; | |||
| HmiRuntimeService runtime_service(repository); | |||
| RuntimeModeService mode_service; | |||
| MainWindow window( | |||
| mode_service, project_service, editor_service, runtime_service); | |||
| window.resize(1000, 640); | |||
| window.show(); | |||
| QApplication::processEvents(); | |||
| QAction *editing_action = requiredChild<QAction>(window, "editingModeAction"); | |||
| QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction"); | |||
| QAction *online_action = requiredChild<QAction>(window, "onlineModeAction"); | |||
| QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction"); | |||
| QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction"); | |||
| QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock"); | |||
| QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | |||
| QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | |||
| QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit"); | |||
| HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>( | |||
| window, "hmiEditorWidget"); | |||
| const qreal compact_scale = hmi_editor->transform().m11(); | |||
| window.resize(1600, 900); | |||
| QApplication::processEvents(); | |||
| require(hmi_editor->transform().m11() > compact_scale, | |||
| "HMI page must refit when the window becomes larger"); | |||
| require(editing_action->isChecked(), "editing action must be selected initially"); | |||
| require(project_dock->isEnabled(), "project dock must be enabled while editing"); | |||
| require(properties_dock->isEnabled(), "properties dock must be enabled while editing"); | |||
| add_button_action->trigger(); | |||
| const std::string page_id = editor_service.firstPageId(); | |||
| require(editor_service.findPage(page_id)->controls.size() == 1, | |||
| "adding a control must update the HMI page model"); | |||
| require(selection->text() == QStringLiteral("button-1"), | |||
| "selecting an added control must update the property panel"); | |||
| require(text_edit->text() == QStringLiteral("按钮"), | |||
| "property panel must show the control text"); | |||
| delete_control_action->trigger(); | |||
| require(editor_service.findPage(page_id)->controls.empty(), | |||
| "deleting a selected control must update the HMI page model"); | |||
| offline_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | |||
| "offline action must enter offline running"); | |||
| require(!project_dock->isEnabled(), | |||
| "project dock must be disabled while running"); | |||
| require(!properties_dock->isEnabled(), | |||
| "properties dock must be disabled while running"); | |||
| require(!add_button_action->isEnabled(), | |||
| "HMI add controls must be disabled while running"); | |||
| online_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | |||
| "running modes must not switch directly through the UI"); | |||
| require(offline_action->isChecked(), | |||
| "failed mode changes must restore the active action"); | |||
| editing_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::Editing, | |||
| "editing action must return to editing"); | |||
| require(project_dock->isEnabled(), | |||
| "project dock must be restored after returning to editing"); | |||
| require(properties_dock->isEnabled(), | |||
| "properties dock must be restored after returning to editing"); | |||
| require(add_button_action->isEnabled(), | |||
| "HMI add controls must be restored after returning to editing"); | |||
| online_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::Editing, | |||
| "online action must require an initial PLC read"); | |||
| require(editing_action->isChecked(), | |||
| "rejected online running must restore the editing action"); | |||
| } | |||
| } // namespace | |||
| int main(int argc, char *argv[]) | |||
| { | |||
| qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); | |||
| QApplication application(argc, argv); | |||
| try | |||
| { | |||
| testModeActionsControlEditingAvailability(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| std::cerr << "main window tests failed: " << error.what() << '\n'; | |||
| return 1; | |||
| } | |||
| std::cout << "main window tests passed\n"; | |||
| return 0; | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| QT += widgets | |||
| TEMPLATE = app | |||
| TARGET = main_window_tests | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| SOURCES += \ | |||
| main_window_tests.cpp \ | |||
| ../src/ui/main_window.cpp \ | |||
| ../src/ui/hmi_editor_widget.cpp \ | |||
| ../src/domain/register_address.cpp \ | |||
| ../src/domain/register_repository.cpp \ | |||
| ../src/domain/hmi_model.cpp \ | |||
| ../src/domain/control_logic_model.cpp \ | |||
| ../src/domain/project_model.cpp \ | |||
| ../src/domain/runtime_state.cpp \ | |||
| ../src/services/project_service.cpp \ | |||
| ../src/services/hmi_editor_service.cpp \ | |||
| ../src/services/hmi_runtime_service.cpp \ | |||
| ../src/services/runtime_mode_service.cpp | |||
| HEADERS += \ | |||
| ../src/ui/main_window.h \ | |||
| ../src/ui/hmi_editor_widget.h \ | |||
| ../src/domain/register_address.h \ | |||
| ../src/domain/register_repository.h \ | |||
| ../src/domain/hmi_model.h \ | |||
| ../src/domain/control_logic_model.h \ | |||
| ../src/domain/project_model.h \ | |||
| ../src/domain/project_storage.h \ | |||
| ../src/domain/runtime_state.h \ | |||
| ../src/services/project_service.h \ | |||
| ../src/services/hmi_editor_service.h \ | |||
| ../src/services/hmi_runtime_service.h \ | |||
| ../src/services/runtime_mode_service.h | |||
| FORMS += \ | |||
| ../src/ui/main_window.ui | |||
| @@ -10,10 +10,6 @@ | |||
| #include <stdexcept> | |||
| #include <string> | |||
| namespace domain = integrated_platform::domain; | |||
| namespace infrastructure = integrated_platform::infrastructure; | |||
| namespace services = integrated_platform::services; | |||
| namespace { | |||
| void require(bool condition, const std::string &message) | |||
| @@ -24,41 +20,68 @@ void require(bool condition, const std::string &message) | |||
| } | |||
| } | |||
| domain::Project makeExampleProject() | |||
| Project makeExampleProject() | |||
| { | |||
| domain::HmiControl start_button; | |||
| HmiControl start_button; | |||
| start_button.id = "start-button"; | |||
| start_button.type = domain::HmiControlType::Button; | |||
| start_button.type = HmiControlType::Button; | |||
| start_button.bounds = {10, 20, 120, 48}; | |||
| start_button.text = "Start"; | |||
| start_button.binding = domain::RegisterAddress{domain::RegisterArea::M, 0}; | |||
| start_button.binding = RegisterAddress{RegisterArea::M, 0}; | |||
| start_button.properties.emplace("color", "green"); | |||
| domain::HmiPage page; | |||
| HmiControl running_indicator; | |||
| running_indicator.id = "running-indicator"; | |||
| running_indicator.type = HmiControlType::Indicator; | |||
| running_indicator.bounds = {150, 20, 64, 64}; | |||
| running_indicator.text = "Running"; | |||
| running_indicator.binding = RegisterAddress{RegisterArea::M, 1}; | |||
| running_indicator.properties.emplace("activeColor", "#24a148"); | |||
| HmiControl temperature_display; | |||
| temperature_display.id = "temperature-display"; | |||
| temperature_display.type = HmiControlType::NumericDisplay; | |||
| temperature_display.bounds = {10, 90, 120, 40}; | |||
| temperature_display.text = "Temperature"; | |||
| temperature_display.binding = RegisterAddress{RegisterArea::D, 2}; | |||
| temperature_display.properties.emplace("format", "decimal"); | |||
| HmiControl target_input; | |||
| target_input.id = "target-input"; | |||
| target_input.type = HmiControlType::NumericInput; | |||
| target_input.bounds = {150, 90, 120, 40}; | |||
| target_input.text = "Target"; | |||
| target_input.binding = RegisterAddress{RegisterArea::D, 3}; | |||
| target_input.properties.emplace("minimum", "-100"); | |||
| HmiPage page; | |||
| page.id = "main-page"; | |||
| page.name = "Main"; | |||
| page.controls.push_back(start_button); | |||
| page.controls.push_back(running_indicator); | |||
| page.controls.push_back(temperature_display); | |||
| page.controls.push_back(target_input); | |||
| domain::LogicNode contact; | |||
| LogicNode contact; | |||
| contact.id = "start-contact"; | |||
| contact.config = domain::ContactNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::M, 0}, | |||
| domain::ContactMode::NormallyOpen}; | |||
| contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| domain::LogicNode compare; | |||
| LogicNode compare; | |||
| compare.id = "temperature-check"; | |||
| compare.config = domain::CompareNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::D, 2}, | |||
| domain::ComparisonOperator::GreaterThanOrEqual, | |||
| compare.config = CompareNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, 2}, | |||
| ComparisonOperator::GreaterThanOrEqual, | |||
| static_cast<std::int16_t>(100)}; | |||
| domain::LogicNode coil; | |||
| LogicNode coil; | |||
| coil.id = "run-coil"; | |||
| coil.config = domain::CoilNodeConfig{ | |||
| domain::RegisterAddress{domain::RegisterArea::M, 1}, | |||
| domain::CoilMode::Set}; | |||
| coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Set}; | |||
| domain::ControlLogic logic; | |||
| ControlLogic logic; | |||
| logic.id = "start-logic"; | |||
| logic.name = "Start logic"; | |||
| logic.enabled = false; | |||
| @@ -66,7 +89,7 @@ domain::Project makeExampleProject() | |||
| logic.connections.push_back({contact.id, compare.id}); | |||
| logic.connections.push_back({compare.id, coil.id}); | |||
| domain::Project project; | |||
| Project project; | |||
| project.metadata = {"example-project", "Example project", "1.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.controlLogics.push_back(logic); | |||
| @@ -92,8 +115,8 @@ void testEmptyProjectRoundTrip() | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| infrastructure::JsonProjectStorage storage; | |||
| services::ProjectService service(storage); | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| require(service.createNewProject("Empty project").succeeded, | |||
| "empty project creation must succeed"); | |||
| @@ -116,8 +139,8 @@ void testExampleProjectRoundTrip() | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| infrastructure::JsonProjectStorage storage; | |||
| services::ProjectService service(storage); | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| service.editProject() = makeExampleProject(); | |||
| const QString first_path = directory.filePath("example.json"); | |||
| @@ -127,14 +150,22 @@ void testExampleProjectRoundTrip() | |||
| require(service.load(first_path.toStdString()).succeeded, | |||
| "example project load must succeed"); | |||
| const domain::Project &project = service.project(); | |||
| const Project &project = service.project(); | |||
| require(project.metadata.id == "example-project", "project id must survive round trip"); | |||
| require(project.hmiPages.size() == 1, "HMI page count must survive round trip"); | |||
| require(project.hmiPages.front().controls.size() == 4, | |||
| "all basic HMI controls must survive round trip"); | |||
| require(project.hmiPages.front().controls.front().binding->area() | |||
| == domain::RegisterArea::M, | |||
| == RegisterArea::M, | |||
| "HMI M binding must survive round trip"); | |||
| require(project.hmiPages.front().controls.front().properties.at("color") == "green", | |||
| "HMI properties must survive round trip"); | |||
| require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator, | |||
| "indicator control type must survive round trip"); | |||
| require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D, | |||
| "numeric display D binding must survive round trip"); | |||
| require(project.hmiPages.front().controls.at(3).bounds.x == 150, | |||
| "numeric input bounds must survive round trip"); | |||
| require(project.controlLogics.size() == 1, | |||
| "control logic count must survive round trip"); | |||
| require(!project.controlLogics.front().enabled, | |||
| @@ -142,7 +173,7 @@ void testExampleProjectRoundTrip() | |||
| require(project.controlLogics.front().nodes.size() == 3, | |||
| "logic node count must survive round trip"); | |||
| const auto &compare = std::get<domain::CompareNodeConfig>( | |||
| const auto &compare = std::get<CompareNodeConfig>( | |||
| project.controlLogics.front().nodes.at(1).config); | |||
| require(compare.address.index() == 2 && compare.value == 100, | |||
| "comparison configuration must survive round trip"); | |||
| @@ -158,8 +189,8 @@ void testInvalidFiles() | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| infrastructure::JsonProjectStorage storage; | |||
| services::ProjectService service(storage); | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| service.editProject().metadata.name = "Current project"; | |||
| const QString invalid_json = directory.filePath("invalid-json.json"); | |||
| const QString missing_field = directory.filePath("missing-field.json"); | |||
| @@ -168,7 +199,7 @@ void testInvalidFiles() | |||
| writeText(invalid_json, "{"); | |||
| auto result = service.load(invalid_json.toStdString()); | |||
| require(!result.succeeded | |||
| && result.storageError == domain::ProjectStorageError::InvalidJson, | |||
| && result.storageError == ProjectStorageError::InvalidJson, | |||
| "invalid JSON must be rejected"); | |||
| require(service.project().metadata.name == "Current project", | |||
| "invalid load must keep current project"); | |||
| @@ -176,13 +207,13 @@ void testInvalidFiles() | |||
| writeText(missing_field, R"({"formatVersion":"1.0"})"); | |||
| result = service.load(missing_field.toStdString()); | |||
| require(!result.succeeded | |||
| && result.storageError == domain::ProjectStorageError::MissingField, | |||
| && result.storageError == ProjectStorageError::MissingField, | |||
| "missing fields must be rejected"); | |||
| writeText(unsupported_version, R"({"formatVersion":"2.0"})"); | |||
| result = service.load(unsupported_version.toStdString()); | |||
| require(!result.succeeded | |||
| && result.storageError == domain::ProjectStorageError::UnsupportedVersion, | |||
| && result.storageError == ProjectStorageError::UnsupportedVersion, | |||
| "unsupported versions must be rejected"); | |||
| } | |||
| @@ -191,12 +222,12 @@ void testServiceStateAndSaveErrors() | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| infrastructure::JsonProjectStorage storage; | |||
| services::ProjectService service(storage); | |||
| require(service.save().error == services::ProjectServiceError::FilePathRequired, | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| require(service.save().error == ProjectServiceError::FilePathRequired, | |||
| "save without a current path must be rejected"); | |||
| require(service.createNewProject(" ").error | |||
| == services::ProjectServiceError::InvalidProjectName, | |||
| == ProjectServiceError::InvalidProjectName, | |||
| "blank project names must be rejected"); | |||
| const QString path = directory.filePath("state.json"); | |||
| @@ -0,0 +1,67 @@ | |||
| #include "services/runtime_mode_service.h" | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| namespace { | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| void testModeTransitions() | |||
| { | |||
| RuntimeModeService service; | |||
| require(service.mode() == ApplicationMode::Editing, | |||
| "service must start in editing mode"); | |||
| require(service.policy().allowsProjectEditing, | |||
| "editing mode must allow project editing"); | |||
| require(service.enterOfflineRunning().succeeded, | |||
| "editing mode must enter offline running"); | |||
| require(service.policy().usesVirtualRegisters, | |||
| "offline running must use virtual registers"); | |||
| require(service.enterOnlineRunning().error | |||
| == ModeTransitionError::MustReturnToEditing, | |||
| "running modes must not switch directly"); | |||
| require(service.enterEditing().succeeded, | |||
| "offline running must return to editing"); | |||
| require(service.enterOnlineRunning().error | |||
| == ModeTransitionError::InitialPlcReadRequired, | |||
| "online running must require an initial PLC read"); | |||
| service.setInitialPlcReadCompleted(true); | |||
| require(service.initialPlcReadCompleted(), | |||
| "service must retain the initial PLC read state"); | |||
| require(service.enterOnlineRunning().succeeded, | |||
| "online running must start after an initial PLC read"); | |||
| require(service.policy().usesPlcRegisters, | |||
| "online running must use PLC registers"); | |||
| require(!service.policy().runsLogicExecutor, | |||
| "online running must keep the software executor stopped"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| { | |||
| try | |||
| { | |||
| testModeTransitions(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| std::cerr << "runtime mode service tests failed: " << error.what() << '\n'; | |||
| return 1; | |||
| } | |||
| std::cout << "runtime mode service tests passed\n"; | |||
| return 0; | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| QT -= gui | |||
| TEMPLATE = app | |||
| TARGET = runtime_mode_service_tests | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| SOURCES += \ | |||
| runtime_mode_service_tests.cpp \ | |||
| ../src/domain/runtime_state.cpp \ | |||
| ../src/services/runtime_mode_service.cpp | |||
| HEADERS += \ | |||
| ../src/domain/runtime_state.h \ | |||
| ../src/services/runtime_mode_service.h | |||
| @@ -2,7 +2,7 @@ | |||
| - Goal: 完成综合平台编程器的工程管理和后续编辑功能。 | |||
| - Branch: `main`。 | |||
| - Current status: 已完成开发顺序第 4 步,工程管理服务、版本化 JSON 存储和核心往返测试已完成。 | |||
| - Current status: 已完成开发顺序第 6 步,HMI 编辑器、工程保存加载闭环和离线 HMI 寄存器适配已完成。 | |||
| - Changed files: | |||
| - `app/integrated_platform.pro` | |||
| - `app/src/main.cpp` | |||
| @@ -32,12 +32,26 @@ | |||
| - `app/src/domain/project_storage.h` | |||
| - `app/src/services/project_service.h` | |||
| - `app/src/services/project_service.cpp` | |||
| - `app/src/services/hmi_editor_service.h` | |||
| - `app/src/services/hmi_editor_service.cpp` | |||
| - `app/src/services/hmi_runtime_service.h` | |||
| - `app/src/services/hmi_runtime_service.cpp` | |||
| - `app/src/services/runtime_mode_service.h` | |||
| - `app/src/services/runtime_mode_service.cpp` | |||
| - `app/src/infrastructure/json_project_storage.h` | |||
| - `app/src/infrastructure/json_project_storage.cpp` | |||
| - `app/tests/domain_tests.pro` | |||
| - `app/tests/domain_tests.cpp` | |||
| - `app/tests/project_management_tests.pro` | |||
| - `app/tests/project_management_tests.cpp` | |||
| - `app/tests/hmi_editor_service_tests.pro` | |||
| - `app/tests/hmi_editor_service_tests.cpp` | |||
| - `app/tests/runtime_mode_service_tests.pro` | |||
| - `app/tests/runtime_mode_service_tests.cpp` | |||
| - `app/tests/main_window_tests.pro` | |||
| - `app/tests/main_window_tests.cpp` | |||
| - `app/src/ui/hmi_editor_widget.h` | |||
| - `app/src/ui/hmi_editor_widget.cpp` | |||
| - Decisions made: | |||
| - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 | |||
| - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 | |||
| @@ -55,6 +69,17 @@ | |||
| - 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。 | |||
| - 测试按业务风险分级 优先覆盖领域规则 服务闭环 工程存储和通信异常 不为简单访问器堆叠单测。 | |||
| - 运行态之间不可直接切换;真机运行态必须先完成 PLC 初次读取,且不运行软件逻辑执行器。 | |||
| - 主窗口采用工程树、中央编辑标签页、属性区、输出区和状态栏组成的可停靠工具布局。 | |||
| - 编辑、离线运行和真机运行共用一个互斥模式入口,切换规则由服务层调用领域状态机。 | |||
| - `RuntimeModeService` 直接复用领域模式、策略和结果类型,不维护一比一的服务层副本。 | |||
| - 运行态禁用工程树和属性编辑区,中央页面保持可见供后续 HMI 运行画面使用。 | |||
| - 当前尚未接入 PLC 通信,真机运行入口会按领域规则提示先连接并完成首次读取。 | |||
| - 项目代码不使用自定义命名空间,分层由目录、接口和依赖方向表达。 | |||
| - `HmiEditorService` 负责页面控件的添加、删除、移动和属性修改,画布项只保存控件标识。 | |||
| - 控件拖动必须限制在页面内,控件标识在页面内唯一,保存前校验必需 M/D 绑定。 | |||
| - 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。 | |||
| - 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。 | |||
| - `HmiRuntimeService` 仅通过 `RegisterRepository` 访问寄存器,不依赖虚拟寄存器、串口或 Modbus。 | |||
| - Validation run and results: | |||
| - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | |||
| - 已启动 `integrated_platform.exe` 并正常退出。 | |||
| @@ -66,6 +91,15 @@ | |||
| - 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。 | |||
| - 在 `build/project-management-tests/` 构建并运行工程管理测试,输出 `project management tests passed`。 | |||
| - 工程管理加入应用后重新执行 Qt 应用构建,构建成功。 | |||
| - Remaining work: 进入开发顺序第 5 步,完成主界面和状态管理。 | |||
| - 在 `build/main-window/` 构建主窗口和运行模式服务,Qt 5.15.2 Debug 构建成功。 | |||
| - 运行模式服务测试输出 `runtime mode service tests passed`。 | |||
| - 主窗口模式工作流测试输出 `main window tests passed`。 | |||
| - 使用当前工作树重新构建并运行领域测试,输出 `domain tests passed`。 | |||
| - 启动 Debug 应用完成窗口响应冒烟检查,程序正常启动和退出。 | |||
| - 移除自定义命名空间后,Release 应用及五个测试工程构建成功,全部测试通过。 | |||
| - 在 `build/hmi-editor/` 重新构建 Release 应用,构建成功并完成启动冒烟检查。 | |||
| - 在 `build/hmi-editor-service-tests/` 构建并运行 HMI 服务测试,输出 `HMI editor service tests passed`。 | |||
| - 重新构建并运行领域、工程管理和主窗口测试,全部通过。 | |||
| - Remaining work: 进入开发顺序第 7 步,实现控制逻辑编辑器。 | |||
| - Known risks / blockers: 无。 | |||
| - Suggested next command: 在主窗口注入 `ProjectService`,接入新建、打开、保存和另存为操作。 | |||
| - Suggested next command: 为控制逻辑建立独立图形编辑器和服务层编辑用例。 | |||
| @@ -4,6 +4,8 @@ | |||
| 项目采用 UI、领域、服务和基础设施分层。HMI 编辑、离线仿真和真机联机使用同一套领域模型,UI 不直接依赖串口或 Modbus 实现。 | |||
| 项目代码不使用自定义命名空间,分层边界由目录、接口和依赖方向表达。 | |||
| ## 目录职责 | |||
| ```text | |||
| @@ -57,7 +59,7 @@ main.cpp -> UI + Services + Infrastructure | |||
| | `register_address.*` | 定义 M/D 地址值对象,统一校验项目地址范围 `0~4000`。 | | |||
| | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | |||
| | `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 | | |||
| | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定。 | | |||
| | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定,并校验控件位于页面边界内。 | | |||
| | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | |||
| | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | |||
| | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | |||
| @@ -66,10 +68,18 @@ main.cpp -> UI + Services + Infrastructure | |||
| 由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功 | |||
| 后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。 | |||
| `services/HmiEditorService` 负责 HMI 页面的控件添加、删除、移动和属性更新,不依赖 Qt 图元。 | |||
| `ui/HmiEditorWidget` 使用 `QGraphicsScene` 将模型投影为画布项,画布项只保存控件标识,移动结束后 | |||
| 才通过编辑服务提交新位置。保存时由 `Project::validate()` 拒绝缺失必需绑定或绑定区域错误的控件。 | |||
| `RegisterRepository` 是 HMI、逻辑执行器和运行服务唯一可见的寄存器边界。离线运行使用 | |||
| `VirtualRegisterRepository`;真机运行后续注入由基础设施层实现的 PLC 缓存仓库。仓库读取的是 | |||
| 最近一次有效值,写入成功只表示请求被仓库接受;真实 PLC 的异步确认由服务层负责反馈。 | |||
| `services/HmiRuntimeService` 仅持有 `RegisterRepository` 接口:按钮切换 M 位,指示灯读取 M 位, | |||
| 数值显示读取 D 字,数值输入写入 D 字。它不依赖虚拟寄存器实现、串口或 Modbus,因此离线和真机 | |||
| 运行可替换数据源而无需改变 HMI 模型或画布代码。 | |||
| 控制逻辑节点使用 `std::variant` 组合独立配置类型。初版只包含 M 区触点、M 区线圈和 D 值与 | |||
| 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | |||
| 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | |||