diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 5d8f3a8..991b718 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -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 diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 50c8f10..c8df7c7 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -2,8 +2,6 @@ #include -namespace integrated_platform::domain { - namespace { // 在提供错误字符串时写入校验失败原因 @@ -159,5 +157,3 @@ bool ControlLogic::validate(std::string *error) const } return true; } - -} // namespace integrated_platform::domain diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index caf303f..e77f7a3 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -7,8 +7,6 @@ #include #include -namespace integrated_platform::domain { - // 触点工作方式 enum class ContactMode { @@ -66,7 +64,7 @@ struct CompareNodeConfig // 控制逻辑节点支持的配置类型 using LogicNodeConfig = std::variant; - + // 描述控制逻辑中的一个功能节点 struct LogicNode { @@ -108,5 +106,3 @@ struct ControlLogic // 校验控制逻辑并通过 error 返回失败原因 bool validate(std::string *error = nullptr) const; }; - -} // namespace integrated_platform::domain diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index b43638f..e1bb29d 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -2,8 +2,6 @@ #include -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 diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 73a0871..a400b61 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -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 #include -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 binding; // 控件绑定的寄存器地址,可以没有绑定;例如按钮绑定 M100 - std::map properties; // 保存控件的扩展属性,例如颜色、字体、最小值等 + std::optional binding; + std::map 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 controls; - // 校验页面配置和控件集合并通过 error 返回失败原因 + /** + * @brief 校验页面尺寸、控件标识唯一性和全部控件配置 + * @param error 可选错误输出,失败时写入首个校验原因 + * @return 页面及其全部控件有效时返回 true + */ bool validate(std::string *error = nullptr) const; }; - -} // namespace integrated_platform::domain diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 101aa2c..881c409 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -2,8 +2,6 @@ #include -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 bool containsDuplicateId(const std::vector &items) { @@ -22,7 +26,10 @@ bool containsDuplicateId(const std::vector &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 diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 7eb27b8..1bbce5b 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -6,8 +6,6 @@ #include #include -namespace integrated_platform::domain { - // 描述工程的基本身份和文件格式信息 struct ProjectMetadata { @@ -32,5 +30,3 @@ struct Project // 校验工程配置并通过 error 返回失败原因 bool validate(std::string *error = nullptr) const; }; - -} // namespace integrated_platform::domain diff --git a/app/src/domain/project_storage.h b/app/src/domain/project_storage.h index 8fbbfb1..f659849 100644 --- a/app/src/domain/project_storage.h +++ b/app/src/domain/project_storage.h @@ -4,8 +4,6 @@ #include -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 diff --git a/app/src/domain/register_address.cpp b/app/src/domain/register_address.cpp index 65969ea..d15d2f6 100644 --- a/app/src/domain/register_address.cpp +++ b/app/src/domain/register_address.cpp @@ -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 diff --git a/app/src/domain/register_address.h b/app/src/domain/register_address.h index 3f5cdd2..9f7978c 100644 --- a/app/src/domain/register_address.h +++ b/app/src/domain/register_address.h @@ -2,8 +2,6 @@ #include -namespace integrated_platform::domain { - enum class RegisterArea { M, @@ -30,5 +28,3 @@ private: RegisterArea area_; int index_; }; - -} // namespace integrated_platform::domain diff --git a/app/src/domain/register_repository.cpp b/app/src/domain/register_repository.cpp index 38ae9b2..56ac50f 100644 --- a/app/src/domain/register_repository.cpp +++ b/app/src/domain/register_repository.cpp @@ -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 diff --git a/app/src/domain/register_repository.h b/app/src/domain/register_repository.h index d382074..9fee8e5 100644 --- a/app/src/domain/register_repository.h +++ b/app/src/domain/register_repository.h @@ -6,8 +6,7 @@ #include #include -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(RegisterAddress::kMaximumIndex + 1); - std::array bits_{}; - std::array words_{}; + std::array bits_{}; // M 区位寄存器内存 + std::array words_{}; // D 区字寄存器内存 }; - -} // namespace integrated_platform::domain diff --git a/app/src/domain/runtime_state.cpp b/app/src/domain/runtime_state.cpp index d94f133..93cb829 100644 --- a/app/src/domain/runtime_state.cpp +++ b/app/src/domain/runtime_state.cpp @@ -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 diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h index 5046a73..f392aeb 100644 --- a/app/src/domain/runtime_state.h +++ b/app/src/domain/runtime_state.h @@ -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 diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index ab334ca..43b706d 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -12,8 +12,6 @@ #include #include -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(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(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 diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index b824b15..dcabc68 100644 --- a/app/src/infrastructure/json_project_storage.h +++ b/app/src/infrastructure/json_project_storage.h @@ -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 diff --git a/app/src/main.cpp b/app/src/main.cpp index b220e76..61bac5a 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -8,6 +8,11 @@ #include +#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(); diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp new file mode 100644 index 0000000..3cfb040 --- /dev/null +++ b/app/src/services/hmi_editor_service.cpp @@ -0,0 +1,365 @@ +#include "hmi_editor_service.h" + +#include "project_service.h" + +#include +#include + +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(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; + } +} diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h new file mode 100644 index 0000000..800902a --- /dev/null +++ b/app/src/services/hmi_editor_service.h @@ -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 + +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_; +}; diff --git a/app/src/services/hmi_runtime_service.cpp b/app/src/services/hmi_runtime_service.cpp new file mode 100644 index 0000000..38751a8 --- /dev/null +++ b/app/src/services/hmi_runtime_service.cpp @@ -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; + } + } +} diff --git a/app/src/services/hmi_runtime_service.h b/app/src/services/hmi_runtime_service.h new file mode 100644 index 0000000..b906579 --- /dev/null +++ b/app/src/services/hmi_runtime_service.h @@ -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 + +/** + * @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_; +}; diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index aca9149..259366b 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -7,8 +7,6 @@ #include #include -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 diff --git a/app/src/services/project_service.h b/app/src/services/project_service.h index cfb124b..4d731f1 100644 --- a/app/src/services/project_service.h +++ b/app/src/services/project_service.h @@ -4,8 +4,6 @@ #include -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 diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp new file mode 100644 index 0000000..44c14b4 --- /dev/null +++ b/app/src/services/runtime_mode_service.cpp @@ -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_; +} diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h new file mode 100644 index 0000000..b0b7372 --- /dev/null +++ b/app/src/services/runtime_mode_service.h @@ -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; +}; diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp new file mode 100644 index 0000000..860ab95 --- /dev/null +++ b/app/src/ui/hmi_editor_widget.cpp @@ -0,0 +1,476 @@ +#include "hmi_editor_widget.h" + +#include "services/hmi_editor_service.h" +#include "services/hmi_runtime_service.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +class HmiGraphicsItem final : public QGraphicsItem +{ +public: + HmiGraphicsItem( + const HmiControl &control, + int page_width, + int page_height, + std::function moved, + std::function 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(control_.bounds.width), + static_cast(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(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(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(page_width_ - control_.bounds.width)); + const qreal maximum_y = std::max( + 0.0, static_cast(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(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 moved_; + std::function activated_; + bool bit_value_ = false; + std::int16_t word_value_ = 0; + bool has_runtime_value_ = false; +}; + +HmiGraphicsItem *asHmiItem(QGraphicsItem *item) +{ + return dynamic_cast(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 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(std::lround(position.x())); + bounds.y = static_cast(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(control->text.size())), + initial, + std::numeric_limits::min(), + std::numeric_limits::max(), + 1, + &accepted); + if (!accepted) + { + return; + } + result = runtime_service_.writeNumericInput( + *control, static_cast(value)); + } + else + { + return; + } + if (!result.succeeded) + { + emit editorError(tr("寄存器操作失败")); + return; + } + refreshRuntimeValues(); +} diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h new file mode 100644 index 0000000..034ee57 --- /dev/null +++ b/app/src/ui/hmi_editor_widget.h @@ -0,0 +1,107 @@ +/** + * @file hmi_editor_widget.h + * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 + * @author suyu + * @date 2026-08-10 + */ + +#pragma once + +#include + +#include + +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; +}; diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index a7d6bc2..9e5f109 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(bytes.size())); +} + +QString fromUtf8(const std::string &value) +{ + return QString::fromUtf8(value.data(), static_cast(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_(std::make_unique()), + 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(control_id_edit_), + static_cast(control_text_edit_), + static_cast(control_x_spin_box_), + static_cast(control_y_spin_box_), + static_cast(control_width_spin_box_), + static_cast(control_height_spin_box_), + static_cast(binding_area_combo_box_), + static_cast(binding_index_spin_box_), + static_cast(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); +} diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 0793cdd..440d2d8 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -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 #include 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_; + /** + * @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 diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 89ef478..95836f2 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -6,72 +6,466 @@ 0 0 + 1280 + 800 + + + + 960 640 - + - + 综合平台编程器 + + QMainWindow::AllowNestedDocks|QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks + - + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + - - - Qt::Vertical - - - - 20 - 180 - + + + true - - - - - - - 20 - true - - - - 综合平台编程器 - - - Qt::AlignCenter - - - - - - - Qt Widgets 开发基线已建立 + + false - - Qt::AlignCenter + + false + + + HMI 页面 + + + + 6 + + + 8 + + + 8 + + + 8 + + + 8 + + + + + + 0 + 0 + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + QFrame::NoFrame + + + + 2 + + + 0 + + + 2 + + + 0 + + + + + 主操作页面 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 800 x 480 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + 0 + 320 + + + + QFrame::StyledPanel + + + + + + 尚未创建 HMI 页面 + + + Qt::AlignCenter + + + + + + + + + + + 控制逻辑 + + + + 12 + + + 12 + + + 12 + + + 12 + + + + + QFrame::StyledPanel + + + + + + 尚未创建控制逻辑 + + + Qt::AlignCenter + + + + + + + + - - - - Qt::Vertical - - - - 20 - 180 - - - - - + + + + 0 + 0 + 1280 + 23 + + + + + 文件(&F) + + + + + + 运行(&R) + + + + + + + + 视图(&V) + + + + + + + + + 运行模式 + + + false + + + Qt::ToolButtonTextBesideIcon + + + TopToolBarArea + + + false + + + + + + + + + 210 + 180 + + + + 工程 + + + 1 + + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + true + + + true + + + + 1 + + + + + HMI 页面 + + + + + 控制逻辑 + + + + + + + + + + + 250 + 180 + + + + 属性 + + + 2 + + + + + 8 + + + 8 + + + 8 + + + 8 + + + + + QFrame::NoFrame + + + true + + + + + 0 + 0 + 232 + 164 + + + + + QFormLayout::AllNonFixedFieldsGrow + + + + + 当前对象 + + + + + + + 未选择 + + + + + + + + + + + + + + 300 + 110 + + + + 输出 + + + 8 + + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + + 系统已就绪 + + + + + + + + + + 退出(&X) + + + + + true + + + true + + + 编辑 + + + 进入编辑态 + + + + + true + + + 离线运行 + + + 进入离线运行态 + + + + + true + + + 真机运行 + + + 进入真机运行态 + + - + \ No newline at end of file diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index b39133e..19c5046 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -11,8 +11,6 @@ #include #include -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(99), 0}.isValid()), + require(!(RegisterAddress{static_cast(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(-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(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"); diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp new file mode 100644 index 0000000..849baa7 --- /dev/null +++ b/app/tests/hmi_editor_service_tests.cpp @@ -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 +#include +#include +#include + +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; +} diff --git a/app/tests/hmi_editor_service_tests.pro b/app/tests/hmi_editor_service_tests.pro new file mode 100644 index 0000000..814ae7a --- /dev/null +++ b/app/tests/hmi_editor_service_tests.pro @@ -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 diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp new file mode 100644 index 0000000..96c032f --- /dev/null +++ b/app/tests/main_window_tests.cpp @@ -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 +#include +#include +#include +#include + +#include +#include +#include + +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 +ObjectType *requiredChild(MainWindow &window, const char *name) +{ + ObjectType *child = window.findChild(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(window, "editingModeAction"); + QAction *offline_action = requiredChild(window, "offlineModeAction"); + QAction *online_action = requiredChild(window, "onlineModeAction"); + QAction *add_button_action = requiredChild(window, "addButtonAction"); + QAction *delete_control_action = requiredChild(window, "deleteControlAction"); + QDockWidget *project_dock = requiredChild(window, "projectDock"); + QDockWidget *properties_dock = requiredChild(window, "propertiesDock"); + QLabel *selection = requiredChild(window, "selectionValueLabel"); + QLineEdit *text_edit = requiredChild(window, "controlTextEdit"); + HmiEditorWidget *hmi_editor = requiredChild( + 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; +} diff --git a/app/tests/main_window_tests.pro b/app/tests/main_window_tests.pro new file mode 100644 index 0000000..1717edb --- /dev/null +++ b/app/tests/main_window_tests.pro @@ -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 diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 4fa8e92..1ac1d62 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -10,10 +10,6 @@ #include #include -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(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( + const auto &compare = std::get( 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"); diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp new file mode 100644 index 0000000..3846beb --- /dev/null +++ b/app/tests/runtime_mode_service_tests.cpp @@ -0,0 +1,67 @@ +#include "services/runtime_mode_service.h" + +#include +#include +#include + +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; +} diff --git a/app/tests/runtime_mode_service_tests.pro b/app/tests/runtime_mode_service_tests.pro new file mode 100644 index 0000000..4be456e --- /dev/null +++ b/app/tests/runtime_mode_service_tests.pro @@ -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 diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md index 98097d7..a2e6579 100644 --- a/docs/ai/handoff.md +++ b/docs/ai/handoff.md @@ -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: 为控制逻辑建立独立图形编辑器和服务层编辑用例。 diff --git a/docs/architecture.md b/docs/architecture.md index 88cc402..4d12066 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 持续 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。