소스 검색

feat: 完成 HMI 编辑器与运行态适配

main
suyu 1 개월 전
부모
커밋
2a3cc16122
40개의 변경된 파일3469개의 추가작업 그리고 408개의 파일을 삭제
  1. +10
    -2
      app/integrated_platform.pro
  2. +0
    -4
      app/src/domain/control_logic_model.cpp
  3. +1
    -5
      app/src/domain/control_logic_model.h
  4. +15
    -4
      app/src/domain/hmi_model.cpp
  5. +47
    -18
      app/src/domain/hmi_model.h
  6. +10
    -5
      app/src/domain/project_model.cpp
  7. +0
    -4
      app/src/domain/project_model.h
  8. +0
    -4
      app/src/domain/project_storage.h
  9. +0
    -4
      app/src/domain/register_address.cpp
  10. +0
    -4
      app/src/domain/register_address.h
  11. +6
    -4
      app/src/domain/register_repository.cpp
  12. +25
    -14
      app/src/domain/register_repository.h
  13. +0
    -4
      app/src/domain/runtime_state.cpp
  14. +0
    -4
      app/src/domain/runtime_state.h
  15. +125
    -129
      app/src/infrastructure/json_project_storage.cpp
  16. +4
    -8
      app/src/infrastructure/json_project_storage.h
  17. +16
    -1
      app/src/main.cpp
  18. +365
    -0
      app/src/services/hmi_editor_service.cpp
  19. +135
    -0
      app/src/services/hmi_editor_service.h
  20. +124
    -0
      app/src/services/hmi_runtime_service.cpp
  21. +86
    -0
      app/src/services/hmi_runtime_service.h
  22. +17
    -21
      app/src/services/project_service.cpp
  23. +8
    -12
      app/src/services/project_service.h
  24. +44
    -0
      app/src/services/runtime_mode_service.cpp
  25. +32
    -0
      app/src/services/runtime_mode_service.h
  26. +476
    -0
      app/src/ui/hmi_editor_widget.cpp
  27. +107
    -0
      app/src/ui/hmi_editor_widget.h
  28. +645
    -13
      app/src/ui/main_window.cpp
  29. +110
    -7
      app/src/ui/main_window.h
  30. +445
    -51
      app/src/ui/main_window.ui
  31. +53
    -43
      app/tests/domain_tests.cpp
  32. +137
    -0
      app/tests/hmi_editor_service_tests.cpp
  33. +29
    -0
      app/tests/hmi_editor_service_tests.pro
  34. +152
    -0
      app/tests/main_window_tests.cpp
  35. +42
    -0
      app/tests/main_window_tests.pro
  36. +70
    -39
      app/tests/project_management_tests.cpp
  37. +67
    -0
      app/tests/runtime_mode_service_tests.cpp
  38. +18
    -0
      app/tests/runtime_mode_service_tests.pro
  39. +37
    -3
      docs/ai/handoff.md
  40. +11
    -1
      docs/architecture.md

+ 10
- 2
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

+ 0
- 4
app/src/domain/control_logic_model.cpp 파일 보기

@@ -2,8 +2,6 @@

#include <algorithm>

namespace integrated_platform::domain {

namespace {

// 在提供错误字符串时写入校验失败原因
@@ -159,5 +157,3 @@ bool ControlLogic::validate(std::string *error) const
}
return true;
}

} // namespace integrated_platform::domain

+ 1
- 5
app/src/domain/control_logic_model.h 파일 보기

@@ -7,8 +7,6 @@
#include <variant>
#include <vector>

namespace integrated_platform::domain {

// 触点工作方式
enum class ContactMode
{
@@ -66,7 +64,7 @@ struct CompareNodeConfig

// 控制逻辑节点支持的配置类型
using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>;
// 描述控制逻辑中的一个功能节点
struct LogicNode
{
@@ -108,5 +106,3 @@ struct ControlLogic
// 校验控制逻辑并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
};

} // namespace integrated_platform::domain

+ 15
- 4
app/src/domain/hmi_model.cpp 파일 보기

@@ -2,8 +2,6 @@

#include <algorithm>

namespace integrated_platform::domain {

namespace {

// 在提供错误字符串时写入校验失败原因
@@ -41,6 +39,14 @@ bool HmiControl::validate(std::string *error) const
setError(error, "HMI control bounds must have positive size");
return false;
}
for (const auto &property : properties)
{
if (property.first.empty())
{
setError(error, "HMI control property names must not be empty");
return false;
}
}
if (requiresBitBinding(type))
{
if (!binding.has_value() || binding->area() != RegisterArea::M)
@@ -86,6 +92,13 @@ bool HmiPage::validate(std::string *error) const
{
return false;
}
if (control.bounds.x < 0 || control.bounds.y < 0
|| control.bounds.x > width - control.bounds.width
|| control.bounds.y > height - control.bounds.height)
{
setError(error, "HMI control bounds must stay within the page");
return false;
}
// 检查控件 id 是否唯一
if (std::find(ids.cbegin(), ids.cend(), control.id) != ids.cend())
{
@@ -96,5 +109,3 @@ bool HmiPage::validate(std::string *error) const
}
return true;
}

} // namespace integrated_platform::domain

+ 47
- 18
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 <string>
#include <vector>

namespace integrated_platform::domain {

// 描述 HMI 控件在页面中的位置和尺寸
/**
* @brief 描述 HMI 控件在页面坐标系中的位置和尺寸
*
* 页面校验要求矩形的左上角非负且完整位于页面范围内
*/
struct HmiRect
{
static constexpr int kDefaultWidth = 80;
@@ -17,35 +26,53 @@ struct HmiRect

int x = 0;
int y = 0;
int width = kDefaultWidth;
int width = kDefaultWidth;
int height = kDefaultHeight;
};

// HMI 控件类型
/**
* @brief HMI 控件的基础类型
*
* 按钮和指示灯只能绑定 M 位,数值控件只能绑定 D 字
*/
enum class HmiControlType
{
Button, // 按钮控件
Indicator, // 指示灯控件
NumericDisplay, // 数值显示控件
NumericInput, // 数值输入控件
Label // 文本标签控件
Button,
Indicator,
NumericDisplay,
NumericInput,
Label
};

// 描述一个 HMI 控件及其显示属性和寄存器绑定关系
/**
* @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置
*
* `properties` 保存未纳入公共字段的字符串扩展属性
*/
struct HmiControl
{
std::string id;
HmiControlType type = HmiControlType::Label;
HmiRect bounds;
std::string text;
std::optional<RegisterAddress> binding; // 控件绑定的寄存器地址,可以没有绑定;例如按钮绑定 M100
std::map<std::string, std::string> properties; // 保存控件的扩展属性,例如颜色、字体、最小值等
std::optional<RegisterAddress> binding;
std::map<std::string, std::string> properties;

// 校验控件配置并通过 error 返回失败原因
/**
* @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定
* @param error 可选错误输出,失败时写入首个校验原因
* @return 配置完整且满足控件类型绑定规则时返回 true
*
* 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址
*/
bool validate(std::string *error = nullptr) const;
};

// 描述一个 HMI 页面及其包含的控件集合
/**
* @brief 描述一个 HMI 页面及其控件集合
*
* 控件标识在同一页面内必须唯一,所有控件必须完整位于页面边界内
*/
struct HmiPage
{
std::string id;
@@ -54,8 +81,10 @@ struct HmiPage
int height = 480;
std::vector<HmiControl> controls;

// 校验页面配置和控件集合并通过 error 返回失败原因
/**
* @brief 校验页面尺寸、控件标识唯一性和全部控件配置
* @param error 可选错误输出,失败时写入首个校验原因
* @return 页面及其全部控件有效时返回 true
*/
bool validate(std::string *error = nullptr) const;
};

} // namespace integrated_platform::domain

+ 10
- 5
app/src/domain/project_model.cpp 파일 보기

@@ -2,8 +2,6 @@

#include <algorithm>

namespace integrated_platform::domain {

namespace {

void setError(std::string *error, const std::string &message)
@@ -14,6 +12,12 @@ void setError(std::string *error, const std::string &message)
}
}

/**
* @brief 检查对象集合中是否存在重复 id
* @tparam TItem 包含 id 成员的对象类型
* @param items 需要检查的对象集合
* @return 存在重复 id 时返回 true 否则返回 false
*/
template <typename TItem>
bool containsDuplicateId(const std::vector<TItem> &items)
{
@@ -22,7 +26,10 @@ bool containsDuplicateId(const std::vector<TItem> &items)
const auto duplicate = std::find_if(
current + 1,
items.cend(),
[&current](const TItem &item) { return item.id == current->id; });
[&current](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

+ 0
- 4
app/src/domain/project_model.h 파일 보기

@@ -6,8 +6,6 @@
#include <string>
#include <vector>

namespace integrated_platform::domain {

// 描述工程的基本身份和文件格式信息
struct ProjectMetadata
{
@@ -32,5 +30,3 @@ struct Project
// 校验工程配置并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
};

} // namespace integrated_platform::domain

+ 0
- 4
app/src/domain/project_storage.h 파일 보기

@@ -4,8 +4,6 @@

#include <string>

namespace integrated_platform::domain {

// 工程文件存储失败原因
enum class ProjectStorageError
{
@@ -59,5 +57,3 @@ public:
*/
virtual ProjectLoadResult load(const std::string &file_path) = 0;
};

} // namespace integrated_platform::domain

+ 0
- 4
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

+ 0
- 4
app/src/domain/register_address.h 파일 보기

@@ -2,8 +2,6 @@

#include <string>

namespace integrated_platform::domain {

enum class RegisterArea
{
M,
@@ -30,5 +28,3 @@ private:
RegisterArea area_;
int index_;
};

} // namespace integrated_platform::domain

+ 6
- 4
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

+ 25
- 14
app/src/domain/register_repository.h 파일 보기

@@ -6,8 +6,7 @@
#include <cstddef>
#include <cstdint>

namespace integrated_platform::domain {

// 寄存器仓库操作结果
enum class RegisterError
{
None, // 操作成功 无错误
@@ -17,56 +16,68 @@ enum class RegisterError
WriteRejected // 仓库拒绝写入请求
};

// M 区位寄存器读取结果
struct BitReadResult
{
bool succeeded = false;
bool value = false;
RegisterError error = RegisterError::Unavailable;
bool succeeded = false; // 读取是否成功
bool value = false; // 读取到的位值
RegisterError error = RegisterError::Unavailable; // 读取失败原因
};

// D 区字寄存器读取结果
struct WordReadResult
{
bool succeeded = false;
std::int16_t value = 0;
RegisterError error = RegisterError::Unavailable;
bool succeeded = false; // 读取是否成功
std::int16_t value = 0; // 读取到的字值
RegisterError error = RegisterError::Unavailable; // 读取失败原因
};

// 寄存器写入结果
struct RegisterWriteResult
{
bool succeeded = false;
RegisterError error = RegisterError::Unavailable;
bool succeeded = false; // 写入是否成功
RegisterError error = RegisterError::Unavailable; // 写入失败原因
};

// M/D 寄存器访问边界
class RegisterRepository
{
public:
virtual ~RegisterRepository() = default;

// 读取 M 区位寄存器
virtual BitReadResult readBit(const RegisterAddress &address) const = 0;
// 写入 M 区位寄存器
virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0;
// 读取 D 区字寄存器
virtual WordReadResult readWord(const RegisterAddress &address) const = 0;
// 写入 D 区字寄存器
virtual RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) = 0;
};

// 基于内存的离线 M/D 寄存器仓库
class VirtualRegisterRepository : public RegisterRepository
{
public:
VirtualRegisterRepository();

// 读取内存中的 M 区位值
BitReadResult readBit(const RegisterAddress &address) const override;
// 更新内存中的 M 区位值
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
// 读取内存中的 D 区字值
WordReadResult readWord(const RegisterAddress &address) const override;
// 更新内存中的 D 区字值
RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override;

// 将所有虚拟寄存器恢复为默认值
void clear();

private:
static constexpr std::size_t kRegisterCount =
static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1);
std::array<bool, kRegisterCount> bits_{};
std::array<std::int16_t, kRegisterCount> words_{};
std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存
std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存
};

} // namespace integrated_platform::domain

+ 0
- 4
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

+ 0
- 4
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

+ 125
- 129
app/src/infrastructure/json_project_storage.cpp 파일 보기

@@ -12,8 +12,6 @@
#include <string>
#include <utility>

namespace integrated_platform::infrastructure {

namespace {

// 当前读写实现支持的工程文件格式版本
@@ -22,13 +20,13 @@ constexpr const char *kCurrentFormatVersion = "1.0";
// 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因
struct ParseState
{
domain::ProjectStorageError error = domain::ProjectStorageError::None;
ProjectStorageError error = ProjectStorageError::None;
std::string message;

// 记录首次解析错误并返回 false,便于解析函数直接向上传播失败
bool fail(domain::ProjectStorageError new_error, const std::string &new_message)
bool fail(ProjectStorageError new_error, const std::string &new_message)
{
if (error == domain::ProjectStorageError::None)
if (error == ProjectStorageError::None)
{
error = new_error;
message = new_message;
@@ -68,7 +66,7 @@ bool readValue(
if (!object.contains(key))
{
return state->fail(
domain::ProjectStorageError::MissingField,
ProjectStorageError::MissingField,
"missing required field " + fieldPath(context, field));
}
*value = object.value(key);
@@ -91,7 +89,7 @@ bool readString(
if (!json_value.isString())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
fieldPath(context, field) + " must be a string");
}
*value = toUtf8(json_value.toString());
@@ -114,7 +112,7 @@ bool readBool(
if (!json_value.isBool())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
fieldPath(context, field) + " must be a boolean");
}
*value = json_value.toBool();
@@ -139,7 +137,7 @@ bool readInt(
if (!json_value.isDouble())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
fieldPath(context, field) + " must be an integer");
}

@@ -149,7 +147,7 @@ bool readInt(
|| number < minimum || number > maximum)
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
fieldPath(context, field) + " is outside the supported integer range");
}
*value = static_cast<int>(number);
@@ -172,7 +170,7 @@ bool readObject(
if (!json_value.isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
fieldPath(context, field) + " must be an object");
}
*value = json_value.toObject();
@@ -195,7 +193,7 @@ bool readArray(
if (!json_value.isArray())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
fieldPath(context, field) + " must be an array");
}
*value = json_value.toArray();
@@ -203,11 +201,11 @@ bool readArray(
}

// 将寄存器地址序列化为包含区域和原始索引的 JSON 对象
QJsonObject serializeAddress(const domain::RegisterAddress &address)
QJsonObject serializeAddress(const RegisterAddress &address)
{
QJsonObject object;
object.insert(QStringLiteral("area"),
address.area() == domain::RegisterArea::M ? QStringLiteral("M")
address.area() == RegisterArea::M ? QStringLiteral("M")
: QStringLiteral("D"));
object.insert(QStringLiteral("index"), address.index());
return object;
@@ -217,7 +215,7 @@ QJsonObject serializeAddress(const domain::RegisterAddress &address)
bool parseAddress(
const QJsonObject &object,
const std::string &context,
domain::RegisterAddress *address,
RegisterAddress *address,
ParseState *state)
{
std::string area_text;
@@ -227,56 +225,56 @@ bool parseAddress(
object,
"index",
context,
domain::RegisterAddress::kMinimumIndex,
domain::RegisterAddress::kMaximumIndex,
RegisterAddress::kMinimumIndex,
RegisterAddress::kMaximumIndex,
&index,
state))
{
return false;
}

domain::RegisterArea area = domain::RegisterArea::M;
RegisterArea area = RegisterArea::M;
if (area_text == "M")
{
area = domain::RegisterArea::M;
area = RegisterArea::M;
}
else if (area_text == "D")
{
area = domain::RegisterArea::D;
area = RegisterArea::D;
}
else
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
context + ".area must be M or D");
}

*address = domain::RegisterAddress{area, index};
*address = RegisterAddress{area, index};
return true;
}

// 将 HMI 控件类型枚举转换为工程文件中的稳定字符串
QString hmiControlTypeName(domain::HmiControlType type)
QString hmiControlTypeName(HmiControlType type)
{
switch (type)
{
case domain::HmiControlType::Button:
case HmiControlType::Button:
{
return QStringLiteral("button");
}
case domain::HmiControlType::Indicator:
case HmiControlType::Indicator:
{
return QStringLiteral("indicator");
}
case domain::HmiControlType::NumericDisplay:
case HmiControlType::NumericDisplay:
{
return QStringLiteral("numericDisplay");
}
case domain::HmiControlType::NumericInput:
case HmiControlType::NumericInput:
{
return QStringLiteral("numericInput");
}
case domain::HmiControlType::Label:
case HmiControlType::Label:
{
return QStringLiteral("label");
}
@@ -289,39 +287,39 @@ QString hmiControlTypeName(domain::HmiControlType type)

// 将工程文件中的控件类型字符串转换为 HMI 控件类型枚举
bool parseHmiControlType(
const std::string &value, domain::HmiControlType *type, ParseState *state)
const std::string &value, HmiControlType *type, ParseState *state)
{
if (value == "button")
{
*type = domain::HmiControlType::Button;
*type = HmiControlType::Button;
}
else if (value == "indicator")
{
*type = domain::HmiControlType::Indicator;
*type = HmiControlType::Indicator;
}
else if (value == "numericDisplay")
{
*type = domain::HmiControlType::NumericDisplay;
*type = HmiControlType::NumericDisplay;
}
else if (value == "numericInput")
{
*type = domain::HmiControlType::NumericInput;
*type = HmiControlType::NumericInput;
}
else if (value == "label")
{
*type = domain::HmiControlType::Label;
*type = HmiControlType::Label;
}
else
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"unsupported HMI control type " + value);
}
return true;
}

// 将 HMI 控件矩形区域序列化为 JSON 对象
QJsonObject serializeBounds(const domain::HmiRect &bounds)
QJsonObject serializeBounds(const HmiRect &bounds)
{
QJsonObject object;
object.insert(QStringLiteral("x"), bounds.x);
@@ -335,7 +333,7 @@ QJsonObject serializeBounds(const domain::HmiRect &bounds)
bool parseBounds(
const QJsonObject &object,
const std::string &context,
domain::HmiRect *bounds,
HmiRect *bounds,
ParseState *state)
{
return readInt(
@@ -394,7 +392,7 @@ bool parseProperties(
if (!current.value().isString())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"HMI control property values must be strings");
}
properties->emplace(toUtf8(current.key()), toUtf8(current.value().toString()));
@@ -403,7 +401,7 @@ bool parseProperties(
}

// 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象
QJsonObject serializeHmiControl(const domain::HmiControl &control)
QJsonObject serializeHmiControl(const HmiControl &control)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(control.id));
@@ -427,7 +425,7 @@ QJsonObject serializeHmiControl(const domain::HmiControl &control)
bool parseHmiControl(
const QJsonObject &object,
const std::string &context,
domain::HmiControl *control,
HmiControl *control,
ParseState *state)
{
std::string type_text;
@@ -459,11 +457,11 @@ bool parseHmiControl(
if (!binding.isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
context + ".binding must be an object or null");
}

domain::RegisterAddress address{domain::RegisterArea::M, 0};
RegisterAddress address{RegisterArea::M, 0};
if (!parseAddress(binding.toObject(), context + ".binding", &address, state))
{
return false;
@@ -473,10 +471,10 @@ bool parseHmiControl(
}

// 将 HMI 页面及其全部控件序列化为 JSON 对象
QJsonObject serializeHmiPage(const domain::HmiPage &page)
QJsonObject serializeHmiPage(const HmiPage &page)
{
QJsonArray controls;
for (const domain::HmiControl &control : page.controls)
for (const HmiControl &control : page.controls)
{
controls.append(serializeHmiControl(control));
}
@@ -494,7 +492,7 @@ QJsonObject serializeHmiPage(const domain::HmiPage &page)
bool parseHmiPage(
const QJsonObject &object,
const std::string &context,
domain::HmiPage *page,
HmiPage *page,
ParseState *state)
{
QJsonArray controls;
@@ -516,10 +514,10 @@ bool parseHmiPage(
if (!controls.at(index).isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
context + ".controls items must be objects");
}
domain::HmiControl control;
HmiControl control;
if (!parseHmiControl(
controls.at(index).toObject(),
context + ".controls[" + std::to_string(index) + ']',
@@ -534,48 +532,48 @@ bool parseHmiPage(
}

// 将触点模式枚举转换为工程文件中的稳定字符串
QString contactModeName(domain::ContactMode mode)
QString contactModeName(ContactMode mode)
{
return mode == domain::ContactMode::NormallyOpen
return mode == ContactMode::NormallyOpen
? QStringLiteral("normallyOpen")
: QStringLiteral("normallyClosed");
}

// 将工程文件中的触点模式字符串转换为枚举
bool parseContactMode(
const std::string &value, domain::ContactMode *mode, ParseState *state)
const std::string &value, ContactMode *mode, ParseState *state)
{
if (value == "normallyOpen")
{
*mode = domain::ContactMode::NormallyOpen;
*mode = ContactMode::NormallyOpen;
}
else if (value == "normallyClosed")
{
*mode = domain::ContactMode::NormallyClosed;
*mode = ContactMode::NormallyClosed;
}
else
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"unsupported contact mode " + value);
}
return true;
}

// 将线圈模式枚举转换为工程文件中的稳定字符串
QString coilModeName(domain::CoilMode mode)
QString coilModeName(CoilMode mode)
{
switch (mode)
{
case domain::CoilMode::Normal:
case CoilMode::Normal:
{
return QStringLiteral("normal");
}
case domain::CoilMode::Set:
case CoilMode::Set:
{
return QStringLiteral("set");
}
case domain::CoilMode::Reset:
case CoilMode::Reset:
{
return QStringLiteral("reset");
}
@@ -587,55 +585,55 @@ QString coilModeName(domain::CoilMode mode)
}

// 将工程文件中的线圈模式字符串转换为枚举
bool parseCoilMode(const std::string &value, domain::CoilMode *mode, ParseState *state)
bool parseCoilMode(const std::string &value, CoilMode *mode, ParseState *state)
{
if (value == "normal")
{
*mode = domain::CoilMode::Normal;
*mode = CoilMode::Normal;
}
else if (value == "set")
{
*mode = domain::CoilMode::Set;
*mode = CoilMode::Set;
}
else if (value == "reset")
{
*mode = domain::CoilMode::Reset;
*mode = CoilMode::Reset;
}
else
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"unsupported coil mode " + value);
}
return true;
}

// 将比较运算符枚举转换为工程文件中的稳定字符串
QString comparisonName(domain::ComparisonOperator comparison)
QString comparisonName(ComparisonOperator comparison)
{
switch (comparison)
{
case domain::ComparisonOperator::Equal:
case ComparisonOperator::Equal:
{
return QStringLiteral("equal");
}
case domain::ComparisonOperator::NotEqual:
case ComparisonOperator::NotEqual:
{
return QStringLiteral("notEqual");
}
case domain::ComparisonOperator::LessThan:
case ComparisonOperator::LessThan:
{
return QStringLiteral("lessThan");
}
case domain::ComparisonOperator::LessThanOrEqual:
case ComparisonOperator::LessThanOrEqual:
{
return QStringLiteral("lessThanOrEqual");
}
case domain::ComparisonOperator::GreaterThan:
case ComparisonOperator::GreaterThan:
{
return QStringLiteral("greaterThan");
}
case domain::ComparisonOperator::GreaterThanOrEqual:
case ComparisonOperator::GreaterThanOrEqual:
{
return QStringLiteral("greaterThanOrEqual");
}
@@ -649,44 +647,44 @@ QString comparisonName(domain::ComparisonOperator comparison)
// 将工程文件中的比较运算符字符串转换为枚举
bool parseComparison(
const std::string &value,
domain::ComparisonOperator *comparison,
ComparisonOperator *comparison,
ParseState *state)
{
if (value == "equal")
{
*comparison = domain::ComparisonOperator::Equal;
*comparison = ComparisonOperator::Equal;
}
else if (value == "notEqual")
{
*comparison = domain::ComparisonOperator::NotEqual;
*comparison = ComparisonOperator::NotEqual;
}
else if (value == "lessThan")
{
*comparison = domain::ComparisonOperator::LessThan;
*comparison = ComparisonOperator::LessThan;
}
else if (value == "lessThanOrEqual")
{
*comparison = domain::ComparisonOperator::LessThanOrEqual;
*comparison = ComparisonOperator::LessThanOrEqual;
}
else if (value == "greaterThan")
{
*comparison = domain::ComparisonOperator::GreaterThan;
*comparison = ComparisonOperator::GreaterThan;
}
else if (value == "greaterThanOrEqual")
{
*comparison = domain::ComparisonOperator::GreaterThanOrEqual;
*comparison = ComparisonOperator::GreaterThanOrEqual;
}
else
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"unsupported comparison operator " + value);
}
return true;
}

// 将触点节点配置序列化,并写入用于反序列化分派的类型标记
QJsonObject serializeNodeConfig(const domain::ContactNodeConfig &config)
QJsonObject serializeNodeConfig(const ContactNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("contact"));
@@ -696,7 +694,7 @@ QJsonObject serializeNodeConfig(const domain::ContactNodeConfig &config)
}

// 将线圈节点配置序列化,并写入用于反序列化分派的类型标记
QJsonObject serializeNodeConfig(const domain::CoilNodeConfig &config)
QJsonObject serializeNodeConfig(const CoilNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("coil"));
@@ -706,7 +704,7 @@ QJsonObject serializeNodeConfig(const domain::CoilNodeConfig &config)
}

// 将数值比较节点配置序列化,并保留有符号 16 位比较常量
QJsonObject serializeNodeConfig(const domain::CompareNodeConfig &config)
QJsonObject serializeNodeConfig(const CompareNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("compare"));
@@ -717,7 +715,7 @@ QJsonObject serializeNodeConfig(const domain::CompareNodeConfig &config)
}

// 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载
QJsonObject serializeLogicNode(const domain::LogicNode &node)
QJsonObject serializeLogicNode(const LogicNode &node)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(node.id));
@@ -737,7 +735,7 @@ QJsonObject serializeLogicNode(const domain::LogicNode &node)
bool parseNodeConfig(
const QJsonObject &object,
const std::string &context,
domain::LogicNodeConfig *config,
LogicNodeConfig *config,
ParseState *state)
{
std::string type;
@@ -748,7 +746,7 @@ bool parseNodeConfig(
return false;
}

domain::RegisterAddress address{domain::RegisterArea::M, 0};
RegisterAddress address{RegisterArea::M, 0};
if (!parseAddress(address_object, context + ".address", &address, state))
{
return false;
@@ -758,32 +756,32 @@ bool parseNodeConfig(
if (type == "contact")
{
std::string mode_text;
domain::ContactMode mode = domain::ContactMode::NormallyOpen;
ContactMode mode = ContactMode::NormallyOpen;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseContactMode(mode_text, &mode, state))
{
return false;
}
*config = domain::ContactNodeConfig{address, mode};
*config = ContactNodeConfig{address, mode};
return true;
}
if (type == "coil")
{
std::string mode_text;
domain::CoilMode mode = domain::CoilMode::Normal;
CoilMode mode = CoilMode::Normal;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseCoilMode(mode_text, &mode, state))
{
return false;
}
*config = domain::CoilNodeConfig{address, mode};
*config = CoilNodeConfig{address, mode};
return true;
}
if (type == "compare")
{
std::string comparison_text;
int value = 0;
domain::ComparisonOperator comparison = domain::ComparisonOperator::Equal;
ComparisonOperator comparison = ComparisonOperator::Equal;
if (!readString(object, "comparison", context, &comparison_text, state)
|| !readInt(
object,
@@ -798,13 +796,13 @@ bool parseNodeConfig(
return false;
}
// 先按 int 校验范围,再安全收窄为领域模型要求的 int16_t
*config = domain::CompareNodeConfig{
*config = CompareNodeConfig{
address, comparison, static_cast<std::int16_t>(value)};
return true;
}

return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"unsupported logic node type " + type);
}

@@ -812,7 +810,7 @@ bool parseNodeConfig(
bool parseLogicNode(
const QJsonObject &object,
const std::string &context,
domain::LogicNode *node,
LogicNode *node,
ParseState *state)
{
QJsonObject config;
@@ -822,7 +820,7 @@ bool parseLogicNode(
}

// 将逻辑节点之间的有向连接序列化为 JSON 对象
QJsonObject serializeConnection(const domain::LogicConnection &connection)
QJsonObject serializeConnection(const LogicConnection &connection)
{
QJsonObject object;
object.insert(QStringLiteral("fromNodeId"), fromUtf8(connection.fromNodeId));
@@ -834,7 +832,7 @@ QJsonObject serializeConnection(const domain::LogicConnection &connection)
bool parseConnection(
const QJsonObject &object,
const std::string &context,
domain::LogicConnection *connection,
LogicConnection *connection,
ParseState *state)
{
return readString(object, "fromNodeId", context, &connection->fromNodeId, state)
@@ -842,16 +840,16 @@ bool parseConnection(
}

// 将一套控制逻辑及其节点和连接序列化为 JSON 对象
QJsonObject serializeControlLogic(const domain::ControlLogic &logic)
QJsonObject serializeControlLogic(const ControlLogic &logic)
{
QJsonArray nodes;
for (const domain::LogicNode &node : logic.nodes)
for (const LogicNode &node : logic.nodes)
{
nodes.append(serializeLogicNode(node));
}

QJsonArray connections;
for (const domain::LogicConnection &connection : logic.connections)
for (const LogicConnection &connection : logic.connections)
{
connections.append(serializeConnection(connection));
}
@@ -869,7 +867,7 @@ QJsonObject serializeControlLogic(const domain::ControlLogic &logic)
bool parseControlLogic(
const QJsonObject &object,
const std::string &context,
domain::ControlLogic *logic,
ControlLogic *logic,
ParseState *state)
{
QJsonArray nodes;
@@ -890,10 +888,10 @@ bool parseControlLogic(
if (!nodes.at(index).isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
context + ".nodes items must be objects");
}
domain::LogicNode node;
LogicNode node;
if (!parseLogicNode(
nodes.at(index).toObject(),
context + ".nodes[" + std::to_string(index) + ']',
@@ -911,10 +909,10 @@ bool parseControlLogic(
if (!connections.at(index).isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
context + ".connections items must be objects");
}
domain::LogicConnection connection;
LogicConnection connection;
if (!parseConnection(
connections.at(index).toObject(),
context + ".connections[" + std::to_string(index) + ']',
@@ -929,16 +927,16 @@ bool parseControlLogic(
}

// 将完整领域工程聚合为工程文件的顶层 JSON 对象
QJsonObject serializeProject(const domain::Project &project)
QJsonObject serializeProject(const Project &project)
{
QJsonArray pages;
for (const domain::HmiPage &page : project.hmiPages)
for (const HmiPage &page : project.hmiPages)
{
pages.append(serializeHmiPage(page));
}

QJsonArray logics;
for (const domain::ControlLogic &logic : project.controlLogics)
for (const ControlLogic &logic : project.controlLogics)
{
logics.append(serializeControlLogic(logic));
}
@@ -954,7 +952,7 @@ QJsonObject serializeProject(const domain::Project &project)

// 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本
bool parseProject(
const QJsonObject &object, domain::Project *project, ParseState *state)
const QJsonObject &object, Project *project, ParseState *state)
{
QJsonArray pages;
QJsonArray logics;
@@ -971,7 +969,7 @@ bool parseProject(
if (project->metadata.formatVersion != kCurrentFormatVersion)
{
return state->fail(
domain::ProjectStorageError::UnsupportedVersion,
ProjectStorageError::UnsupportedVersion,
"unsupported project format version " + project->metadata.formatVersion);
}

@@ -990,10 +988,10 @@ bool parseProject(
if (!pages.at(index).isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"project.hmiPages items must be objects");
}
domain::HmiPage page;
HmiPage page;
if (!parseHmiPage(
pages.at(index).toObject(),
"project.hmiPages[" + std::to_string(index) + ']',
@@ -1011,10 +1009,10 @@ bool parseProject(
if (!logics.at(index).isObject())
{
return state->fail(
domain::ProjectStorageError::InvalidField,
ProjectStorageError::InvalidField,
"project.controlLogics items must be objects");
}
domain::ControlLogic logic;
ControlLogic logic;
if (!parseControlLogic(
logics.at(index).toObject(),
"project.controlLogics[" + std::to_string(index) + ']',
@@ -1029,8 +1027,8 @@ bool parseProject(
}

// 将 Qt 文件错误转换为领域层统一的工程保存失败结果
domain::ProjectSaveResult saveFailure(
domain::ProjectStorageError error, const QString &message)
ProjectSaveResult saveFailure(
ProjectStorageError error, const QString &message)
{
return {false, error, toUtf8(message)};
}
@@ -1038,19 +1036,19 @@ domain::ProjectSaveResult saveFailure(
} // namespace

// 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件
domain::ProjectSaveResult JsonProjectStorage::save(
const domain::Project &project, const std::string &file_path)
ProjectSaveResult JsonProjectStorage::save(
const Project &project, const std::string &file_path)
{
// 写文件前先执行领域校验,防止持久化内部关系不合法的工程
std::string validation_error;
if (!project.validate(&validation_error))
{
return {false, domain::ProjectStorageError::InvalidProject, validation_error};
return {false, ProjectStorageError::InvalidProject, validation_error};
}
if (project.metadata.formatVersion != kCurrentFormatVersion)
{
return {false,
domain::ProjectStorageError::UnsupportedVersion,
ProjectStorageError::UnsupportedVersion,
"unsupported project format version " + project.metadata.formatVersion};
}

@@ -1058,7 +1056,7 @@ domain::ProjectSaveResult JsonProjectStorage::save(
QSaveFile file(fromUtf8(file_path));
if (!file.open(QIODevice::WriteOnly))
{
return saveFailure(domain::ProjectStorageError::FileOpenFailed, file.errorString());
return saveFailure(ProjectStorageError::FileOpenFailed, file.errorString());
}

const QByteArray data = QJsonDocument(serializeProject(project)).toJson(
@@ -1067,24 +1065,24 @@ domain::ProjectSaveResult JsonProjectStorage::save(
if (file.write(data) != data.size())
{
file.cancelWriting();
return saveFailure(domain::ProjectStorageError::FileWriteFailed, file.errorString());
return saveFailure(ProjectStorageError::FileWriteFailed, file.errorString());
}
if (!file.commit())
{
return saveFailure(domain::ProjectStorageError::FileWriteFailed, file.errorString());
return saveFailure(ProjectStorageError::FileWriteFailed, file.errorString());
}
return {true, domain::ProjectStorageError::None, {}};
return {true, ProjectStorageError::None, {}};
}

// 读取并解析工程文件,全部校验通过后才返回新的领域工程
domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
{
QFile file(fromUtf8(file_path));
if (!file.open(QIODevice::ReadOnly))
{
return {false,
{},
domain::ProjectStorageError::FileOpenFailed,
ProjectStorageError::FileOpenFailed,
toUtf8(file.errorString())};
}

@@ -1093,7 +1091,7 @@ domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
{
return {false,
{},
domain::ProjectStorageError::FileReadFailed,
ProjectStorageError::FileReadFailed,
toUtf8(file.errorString())};
}

@@ -1107,12 +1105,12 @@ domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
: parse_error.errorString();
return {false,
{},
domain::ProjectStorageError::InvalidJson,
ProjectStorageError::InvalidJson,
toUtf8(message)};
}

// 先在局部对象中完成结构解析,失败时不会暴露半成品工程
domain::Project project;
Project project;
ParseState state;
if (!parseProject(document.object(), &project, &state))
{
@@ -1125,10 +1123,8 @@ domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
{
return {false,
{},
domain::ProjectStorageError::InvalidProject,
ProjectStorageError::InvalidProject,
validation_error};
}
return {true, std::move(project), domain::ProjectStorageError::None, {}};
return {true, std::move(project), ProjectStorageError::None, {}};
}

} // namespace integrated_platform::infrastructure

+ 4
- 8
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

+ 16
- 1
app/src/main.cpp 파일 보기

@@ -8,6 +8,11 @@

#include <QApplication>

#include "infrastructure/json_project_storage.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui/main_window.h"

int main(int argc, char *argv[])
@@ -16,7 +21,17 @@ int main(int argc, char *argv[])
application.setApplicationName(QObject::tr("综合平台编程器"));
application.setOrganizationName(QStringLiteral("QtProXinJe"));

integrated_platform::MainWindow main_window;
JsonProjectStorage project_storage;
ProjectService project_service(project_storage);
HmiEditorService hmi_editor_service(project_service);
VirtualRegisterRepository virtual_register_repository;
HmiRuntimeService hmi_runtime_service(virtual_register_repository);
RuntimeModeService runtime_mode_service;
MainWindow main_window(
runtime_mode_service,
project_service,
hmi_editor_service,
hmi_runtime_service);
main_window.show();

return application.exec();


+ 365
- 0
app/src/services/hmi_editor_service.cpp 파일 보기

@@ -0,0 +1,365 @@
#include "hmi_editor_service.h"

#include "project_service.h"

#include <algorithm>
#include <utility>

namespace {

void setError(std::string *error, const std::string &message)
{
if (error != nullptr)
{
*error = message;
}
}

std::string controlPrefix(HmiControlType type)
{
switch (type)
{
case HmiControlType::Button:
{
return "button";
}
case HmiControlType::Indicator:
{
return "indicator";
}
case HmiControlType::NumericDisplay:
{
return "numeric-display";
}
case HmiControlType::NumericInput:
{
return "numeric-input";
}
case HmiControlType::Label:
default:
{
return "label";
}
}
}

std::string defaultText(HmiControlType type)
{
switch (type)
{
case HmiControlType::Button:
{
return "按钮";
}
case HmiControlType::Indicator:
{
return "指示灯";
}
case HmiControlType::NumericDisplay:
{
return "数值显示";
}
case HmiControlType::NumericInput:
{
return "数值输入";
}
case HmiControlType::Label:
default:
{
return "文本";
}
}
}

HmiEditorResult failure(HmiEditorError error, const std::string &message)
{
return {false, error, message, {}};
}

} // namespace

HmiEditorService::HmiEditorService(ProjectService &project_service)
: project_service_(project_service)
{
}

const HmiPage *HmiEditorService::findPage(const std::string &page_id) const
{
const Project &project = project_service_.project();
const auto page = std::find_if(
project.hmiPages.cbegin(),
project.hmiPages.cend(),
[&page_id](const HmiPage &candidate)
{
return candidate.id == page_id;
});
return page == project.hmiPages.cend() ? nullptr : &*page;
}

const HmiControl *HmiEditorService::findControl(
const std::string &page_id, const std::string &control_id) const
{
const HmiPage *page = findPage(page_id);
if (page == nullptr)
{
return nullptr;
}
const auto control = std::find_if(
page->controls.cbegin(),
page->controls.cend(),
[&control_id](const HmiControl &candidate)
{
return candidate.id == control_id;
});
return control == page->controls.cend() ? nullptr : &*control;
}

std::string HmiEditorService::firstPageId() const
{
const Project &project = project_service_.project();
return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id;
}

HmiEditorResult HmiEditorService::ensureDefaultPage()
{
if (!project_service_.project().hmiPages.empty())
{
return {true, HmiEditorError::None, {}, firstPageId()};
}

HmiPage page;
page.id = "page-1";
page.name = "主操作页面";

Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

HmiEditorResult HmiEditorService::addControl(
const std::string &page_id, HmiControlType type)
{
const HmiPage *page = findPage(page_id);
if (page == nullptr)
{
return failure(HmiEditorError::PageNotFound, "HMI page was not found");
}

HmiControl control = makeControl(*page, type);
Project &project = project_service_.editProject();
auto target_page = std::find_if(
project.hmiPages.begin(),
project.hmiPages.end(),
[&page_id](const HmiPage &candidate)
{
return candidate.id == page_id;
});
target_page->controls.push_back(std::move(control));
return {true,
HmiEditorError::None,
{},
target_page->controls.back().id};
}

HmiEditorResult HmiEditorService::removeControl(
const std::string &page_id, const std::string &control_id)
{
if (findPage(page_id) == nullptr)
{
return failure(HmiEditorError::PageNotFound, "HMI page was not found");
}
if (findControl(page_id, control_id) == nullptr)
{
return failure(HmiEditorError::ControlNotFound, "HMI control was not found");
}

Project &project = project_service_.editProject();
auto page = std::find_if(
project.hmiPages.begin(),
project.hmiPages.end(),
[&page_id](const HmiPage &candidate)
{
return candidate.id == page_id;
});
page->controls.erase(
std::remove_if(
page->controls.begin(),
page->controls.end(),
[&control_id](const HmiControl &candidate)
{
return candidate.id == control_id;
}),
page->controls.end());
return {true, HmiEditorError::None, {}, control_id};
}

HmiEditorResult HmiEditorService::moveControl(
const std::string &page_id,
const std::string &control_id,
const HmiRect &bounds)
{
const HmiPage *page = findPage(page_id);
const HmiControl *control = findControl(page_id, control_id);
if (page == nullptr)
{
return failure(HmiEditorError::PageNotFound, "HMI page was not found");
}
if (control == nullptr)
{
return failure(HmiEditorError::ControlNotFound, "HMI control was not found");
}

HmiControl candidate = *control;
candidate.bounds = bounds;
std::string error;
if (!validateEditableControl(*page, candidate, &error))
{
return failure(HmiEditorError::InvalidControl, error);
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &item) { return item.id == page_id; });
auto editable = std::find_if(
target->controls.begin(), target->controls.end(),
[&control_id](const HmiControl &item) { return item.id == control_id; });
editable->bounds = bounds;
return {true, HmiEditorError::None, {}, control_id};
}

HmiEditorResult HmiEditorService::updateControl(
const std::string &page_id,
const std::string &control_id,
const HmiControl &control)
{
const HmiPage *page = findPage(page_id);
const HmiControl *existing = findControl(page_id, control_id);
if (page == nullptr)
{
return failure(HmiEditorError::PageNotFound, "HMI page was not found");
}
if (existing == nullptr)
{
return failure(HmiEditorError::ControlNotFound, "HMI control was not found");
}
if (control.type != existing->type)
{
return failure(HmiEditorError::InvalidControl, "HMI control type cannot be changed");
}

std::string error;
if (!validateEditableControl(*page, control, &error))
{
return failure(HmiEditorError::InvalidControl, error);
}
if (hasDuplicateControlId(*page, control_id, control.id))
{
return failure(HmiEditorError::DuplicateId, "HMI control id must be unique within a page");
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &item) { return item.id == page_id; });
auto editable = std::find_if(
target->controls.begin(), target->controls.end(),
[&control_id](const HmiControl &item) { return item.id == control_id; });
*editable = control;
return {true, HmiEditorError::None, {}, control.id};
}

bool HmiEditorService::validateEditableControl(
const HmiPage &page, const HmiControl &control, std::string *error)
{
if (control.id.empty())
{
setError(error, "HMI control id must not be empty");
return false;
}
if (control.bounds.width <= 0 || control.bounds.height <= 0
|| control.bounds.x < 0 || control.bounds.y < 0
|| control.bounds.width > page.width || control.bounds.height > page.height
|| control.bounds.x > page.width - control.bounds.width
|| control.bounds.y > page.height - control.bounds.height)
{
setError(error, "HMI control bounds must stay within the page");
return false;
}
for (const auto &property : control.properties)
{
if (property.first.empty())
{
setError(error, "HMI control property names must not be empty");
return false;
}
}
if (control.binding.has_value() && !control.binding->isValid())
{
setError(error, "HMI control binding has an invalid address");
return false;
}
if (control.binding.has_value()
&& (control.type == HmiControlType::Button
|| control.type == HmiControlType::Indicator)
&& control.binding->area() != RegisterArea::M)
{
setError(error, "button and indicator controls require an M address");
return false;
}
if (control.binding.has_value()
&& (control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput)
&& control.binding->area() != RegisterArea::D)
{
setError(error, "numeric controls require a D address");
return false;
}
return true;
}

bool HmiEditorService::hasDuplicateControlId(
const HmiPage &page,
const std::string &excluded_id,
const std::string &candidate_id)
{
return std::any_of(
page.controls.cbegin(), page.controls.cend(),
[&excluded_id, &candidate_id](const HmiControl &item)
{
return item.id != excluded_id && item.id == candidate_id;
});
}

HmiControl HmiEditorService::makeControl(
const HmiPage &page, HmiControlType type)
{
HmiControl control;
control.id = makeUniqueId(page, controlPrefix(type));
control.type = type;
control.text = defaultText(type);
control.bounds.width = type == HmiControlType::Indicator ? 64 : 120;
control.bounds.height = type == HmiControlType::Indicator ? 64 : 40;
const int offset = static_cast<int>(page.controls.size()) * 16;
control.bounds.x = std::min(20 + offset, page.width - control.bounds.width);
control.bounds.y = std::min(20 + offset, page.height - control.bounds.height);
return control;
}

std::string HmiEditorService::makeUniqueId(
const HmiPage &page, const std::string &prefix)
{
int suffix = 1;
while (true)
{
const std::string candidate = prefix + '-' + std::to_string(suffix);
const bool found = std::any_of(
page.controls.cbegin(), page.controls.cend(),
[&candidate](const HmiControl &item) { return item.id == candidate; });
if (!found)
{
return candidate;
}
++suffix;
}
}

+ 135
- 0
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 <string>

class ProjectService;

/**
* @brief HMI 编辑操作的失败分类
*/
enum class HmiEditorError
{
None,
PageNotFound,
ControlNotFound,
DuplicateId,
InvalidPage,
InvalidControl
};

/**
* @brief HMI 编辑操作的统一结果
*
* 成功时 `id` 保存新建或更新后的控件或页面标识
*/
struct HmiEditorResult
{
bool succeeded = false;
HmiEditorError error = HmiEditorError::None;
std::string message;
std::string id;
};

/**
* @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图
*
* 服务通过 ProjectService 修改工程并负责标记工程为已修改
*/
class HmiEditorService
{
public:
explicit HmiEditorService(ProjectService &project_service);

/**
* @brief 按页面标识查找只读页面
* @param page_id 页面唯一标识
* @return 找到时返回页面指针,未找到时返回空指针
*/
const HmiPage *findPage(const std::string &page_id) const;
/**
* @brief 按页面和控件标识查找只读控件
* @param page_id 所属页面唯一标识
* @param control_id 控件唯一标识
* @return 找到时返回控件指针,任一标识不存在时返回空指针
*/
const HmiControl *findControl(
const std::string &page_id, const std::string &control_id) const;
/**
* @brief 返回工程中第一个 HMI 页面标识
* @return 工程没有 HMI 页面时返回空字符串
*/
std::string firstPageId() const;

/**
* @brief 在工程没有 HMI 页面时创建默认操作页
* @return 已有或新建页面的成功结果及其页面标识
*/
HmiEditorResult ensureDefaultPage();
/**
* @brief 向指定页面添加待配置的基础控件
* @param page_id 目标页面唯一标识
* @param type 新控件类型
* @return 成功时返回新控件标识,页面不存在时返回 PageNotFound
*
* 新控件可暂时没有寄存器绑定,完整绑定校验在工程保存前执行
*/
HmiEditorResult addControl(
const std::string &page_id, HmiControlType type);
/**
* @brief 删除指定页面中的控件
* @param page_id 所属页面唯一标识
* @param control_id 待删除控件唯一标识
* @return 页面或控件不存在时返回对应错误
*/
HmiEditorResult removeControl(
const std::string &page_id, const std::string &control_id);
/**
* @brief 更新控件位置和尺寸
* @param page_id 所属页面唯一标识
* @param control_id 待移动控件唯一标识
* @param bounds 新矩形,必须完整位于页面范围内
* @return 页面、控件或矩形无效时返回失败结果
*/
HmiEditorResult moveControl(
const std::string &page_id,
const std::string &control_id,
const HmiRect &bounds);
/**
* @brief 更新控件可编辑属性
* @param page_id 所属页面唯一标识
* @param control_id 原控件唯一标识
* @param control 更新后的控件配置,不允许改变控件类型
* @return 控件标识重复、绑定区域错误或矩形越界时返回失败结果
*
* 允许控件暂时没有必需绑定,完整工程校验仍在保存前执行
*/
HmiEditorResult updateControl(
const std::string &page_id,
const std::string &control_id,
const HmiControl &control);

private:
static bool validateEditableControl(
const HmiPage &page,
const HmiControl &control,
std::string *error);
static bool hasDuplicateControlId(
const HmiPage &page,
const std::string &excluded_id,
const std::string &candidate_id);
static HmiControl makeControl(
const HmiPage &page, HmiControlType type);
static std::string makeUniqueId(
const HmiPage &page, const std::string &prefix);

ProjectService &project_service_;
};

+ 124
- 0
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;
}
}
}

+ 86
- 0
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 <cstdint>

/**
* @brief HMI 运行态读写失败分类
*/
enum class HmiRuntimeError
{
None,
UnsupportedControl,
MissingBinding,
InvalidBinding,
RepositoryFailure
};

/**
* @brief HMI 控件寄存器读取结果
*
* 控件类型决定 `bit_value` 或 `word_value` 哪一个有效
*/
struct HmiRuntimeReadResult
{
bool succeeded = false;
HmiRuntimeError error = HmiRuntimeError::None;
bool bit_value = false;
std::int16_t word_value = 0;
};

/**
* @brief HMI 控件寄存器写入结果
*/
struct HmiRuntimeWriteResult
{
bool succeeded = false;
HmiRuntimeError error = HmiRuntimeError::None;
};

/**
* @brief 仅通过统一寄存器仓库执行 HMI 运行态读写
*
* 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库
*/
class HmiRuntimeService
{
public:
explicit HmiRuntimeService(RegisterRepository &repository);

/**
* @brief 读取控件绑定的寄存器当前值
* @param control 待读取控件
* @return 按钮和指示灯返回 M 位,数值控件返回 D 字,标签返回 UnsupportedControl
*
* 控件未绑定、绑定区域错误或仓库不可用时不返回有效值
*/
HmiRuntimeReadResult readControl(const HmiControl &control) const;
/**
* @brief 切换按钮当前绑定 M 位的值
* @param control 按钮控件,必须绑定有效 M 地址
* @return 非按钮、绑定无效或仓库拒绝写入时返回失败结果
*/
HmiRuntimeWriteResult toggleButton(const HmiControl &control);
/**
* @brief 将带符号 16 位数值写入数值输入控件绑定的 D 地址
* @param control 数值输入控件,必须绑定有效 D 地址
* @param value 要写入的 D 字值
* @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果
*/
HmiRuntimeWriteResult writeNumericInput(
const HmiControl &control, std::int16_t value);

private:
static HmiRuntimeError repositoryError(RegisterError error);

RegisterRepository &repository_;
};

+ 17
- 21
app/src/services/project_service.cpp 파일 보기

@@ -7,8 +7,6 @@
#include <sstream>
#include <utility>

namespace integrated_platform::services {

namespace {

// 使用高精度时间戳和随机值生成工程标识
@@ -26,18 +24,18 @@ std::string generateProjectId()

} // namespace

ProjectService::ProjectService(domain::ProjectStorage &storage)
ProjectService::ProjectService(ProjectStorage &storage)
: storage_(storage),
project_(makeNewProject("Untitled"))
{
}

const domain::Project &ProjectService::project() const
const Project &ProjectService::project() const
{
return project_;
}

domain::Project &ProjectService::editProject()
Project &ProjectService::editProject()
{
modified_ = true;
return project_;
@@ -64,7 +62,7 @@ ProjectOperationResult ProjectService::createNewProject(const std::string &name)
{
return {false,
ProjectServiceError::InvalidProjectName,
domain::ProjectStorageError::None,
ProjectStorageError::None,
"project name must not be empty"};
}

@@ -72,7 +70,7 @@ ProjectOperationResult ProjectService::createNewProject(const std::string &name)
project_ = makeNewProject(name);
current_file_path_.clear();
modified_ = true;
return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}};
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::save()
@@ -81,7 +79,7 @@ ProjectOperationResult ProjectService::save()
{
return {false,
ProjectServiceError::FilePathRequired,
domain::ProjectStorageError::None,
ProjectStorageError::None,
"project file path is required"};
}
return saveAs(current_file_path_);
@@ -93,7 +91,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
{
return {false,
ProjectServiceError::FilePathRequired,
domain::ProjectStorageError::None,
ProjectStorageError::None,
"project file path is required"};
}

@@ -102,12 +100,12 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
{
return {false,
ProjectServiceError::InvalidProject,
domain::ProjectStorageError::InvalidProject,
ProjectStorageError::InvalidProject,
validation_error};
}

// 先完成领域校验,避免将非法工程交给存储层
const domain::ProjectSaveResult result = storage_.save(project_, file_path);
const ProjectSaveResult result = storage_.save(project_, file_path);
if (!result.succeeded)
{
return storageFailure(result.error, result.message);
@@ -116,7 +114,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
// 只有存储成功后才更新文件关联和未保存状态
current_file_path_ = file_path;
modified_ = false;
return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}};
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::load(const std::string &file_path)
@@ -125,11 +123,11 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
{
return {false,
ProjectServiceError::FilePathRequired,
domain::ProjectStorageError::None,
ProjectStorageError::None,
"project file path is required"};
}

domain::ProjectLoadResult result = storage_.load(file_path);
ProjectLoadResult result = storage_.load(file_path);
if (!result.succeeded)
{
return storageFailure(result.error, result.message);
@@ -140,7 +138,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
{
return {false,
ProjectServiceError::InvalidProject,
domain::ProjectStorageError::InvalidProject,
ProjectStorageError::InvalidProject,
validation_error};
}

@@ -148,12 +146,12 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
project_ = std::move(result.project);
current_file_path_ = file_path;
modified_ = false;
return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}};
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

domain::Project ProjectService::makeNewProject(const std::string &name)
Project ProjectService::makeNewProject(const std::string &name)
{
domain::Project project;
Project project;
project.metadata.id = generateProjectId();
project.metadata.name = name;
project.metadata.formatVersion = "1.0";
@@ -173,9 +171,7 @@ bool ProjectService::isBlank(const std::string &value)
}

ProjectOperationResult ProjectService::storageFailure(
domain::ProjectStorageError error, const std::string &message)
ProjectStorageError error, const std::string &message)
{
return {false, ProjectServiceError::StorageFailure, error, message};
}

} // namespace integrated_platform::services

+ 8
- 12
app/src/services/project_service.h 파일 보기

@@ -4,8 +4,6 @@

#include <string>

namespace integrated_platform::services {

// 工程管理操作失败原因
enum class ProjectServiceError
{
@@ -21,7 +19,7 @@ struct ProjectOperationResult
{
bool succeeded = false; // 操作是否成功
ProjectServiceError error = ProjectServiceError::None; // 服务层错误原因
domain::ProjectStorageError storageError = domain::ProjectStorageError::None; // 存储层错误原因
ProjectStorageError storageError = ProjectStorageError::None; // 存储层错误原因
std::string message; // 面向调用方的结果描述
};

@@ -30,10 +28,10 @@ class ProjectService
{
public:
// 使用外部提供的工程存储实现创建服务
explicit ProjectService(domain::ProjectStorage &storage);
explicit ProjectService(ProjectStorage &storage);

const domain::Project &project() const;
domain::Project &editProject();
const Project &project() const;
Project &editProject();
const std::string &currentFilePath() 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

+ 44
- 0
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_;
}

+ 32
- 0
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;
};

+ 476
- 0
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 <QGraphicsItem>
#include <QGraphicsRectItem>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QInputDialog>
#include <QPainter>
#include <QResizeEvent>
#include <QStyleOptionGraphicsItem>

#include <algorithm>
#include <cmath>
#include <cstdint>
#include <functional>
#include <limits>

namespace {

class HmiGraphicsItem final : public QGraphicsItem
{
public:
HmiGraphicsItem(
const HmiControl &control,
int page_width,
int page_height,
std::function<void(const std::string &, const QPointF &)> moved,
std::function<void(const std::string &)> activated)
: control_(control),
page_width_(page_width),
page_height_(page_height),
moved_(std::move(moved)),
activated_(std::move(activated))
{
setPos(control_.bounds.x, control_.bounds.y);
setFlag(ItemIsSelectable, true);
setAcceptedMouseButtons(Qt::LeftButton);
}

QRectF boundingRect() const override
{
return {0,
0,
static_cast<qreal>(control_.bounds.width),
static_cast<qreal>(control_.bounds.height)};
}

void paint(
QPainter *painter,
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
const QRectF rect = boundingRect().adjusted(1, 1, -1, -1);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1));

switch (control_.type)
{
case HmiControlType::Button:
{
painter->setBrush(QColor(QStringLiteral("#dcece3")));
painter->drawRoundedRect(rect, 4, 4);
painter->setPen(QColor(QStringLiteral("#205c3b")));
painter->drawText(rect, Qt::AlignCenter, textWithValue());
break;
}
case HmiControlType::Indicator:
{
const qreal diameter = std::min(rect.width(), rect.height() - 18.0);
const QRectF lamp(
rect.center().x() - diameter / 2.0,
rect.top() + 2,
diameter,
diameter);
painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148"))
: QColor(QStringLiteral("#b8c1c8")));
painter->drawEllipse(lamp);
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->drawText(
QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16),
Qt::AlignCenter,
QString::fromUtf8(control_.text.data(),
static_cast<int>(control_.text.size())));
break;
}
case HmiControlType::NumericDisplay:
{
painter->setBrush(QColor(QStringLiteral("#edf2f6")));
painter->drawRect(rect);
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->drawText(rect.adjusted(7, 0, -7, 0),
Qt::AlignVCenter | Qt::AlignLeft,
textWithValue());
break;
}
case HmiControlType::NumericInput:
{
painter->setBrush(QColor(QStringLiteral("#ffffff")));
painter->drawRoundedRect(rect, 3, 3);
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->drawText(rect.adjusted(7, 0, -7, 0),
Qt::AlignVCenter | Qt::AlignLeft,
textWithValue());
break;
}
case HmiControlType::Label:
default:
{
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->drawText(rect, Qt::AlignCenter,
QString::fromUtf8(control_.text.data(),
static_cast<int>(control_.text.size())));
break;
}
}

if ((option->state & QStyle::State_Selected) != 0)
{
painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2));
painter->drawRect(boundingRect().adjusted(0, 0, -1, -1));
}
}

const std::string &controlId() const
{
return control_.id;
}

void setInteractionEnabled(bool editable)
{
setFlag(ItemIsMovable, editable);
}

void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available)
{
bit_value_ = bit_value;
word_value_ = word_value;
has_runtime_value_ = available;
update();
}

protected:
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override
{
if (change == ItemPositionChange && flags().testFlag(ItemIsMovable))
{
QPointF position = value.toPointF();
const qreal maximum_x = std::max(
0.0, static_cast<double>(page_width_ - control_.bounds.width));
const qreal maximum_y = std::max(
0.0, static_cast<double>(page_height_ - control_.bounds.height));
position.setX(std::clamp(position.x(), 0.0, maximum_x));
position.setY(std::clamp(position.y(), 0.0, maximum_y));
return position;
}
return QGraphicsItem::itemChange(change, value);
}

void mousePressEvent(QGraphicsSceneMouseEvent *event) override
{
if (!flags().testFlag(ItemIsMovable)
&& control_.type == HmiControlType::Button && activated_)
{
setSelected(true);
activated_(control_.id);
event->accept();
return;
}
QGraphicsItem::mousePressEvent(event);
}

void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override
{
if (!flags().testFlag(ItemIsMovable)
&& control_.type == HmiControlType::NumericInput && activated_)
{
setSelected(true);
activated_(control_.id);
event->accept();
return;
}
QGraphicsItem::mouseDoubleClickEvent(event);
}

void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override
{
QGraphicsItem::mouseReleaseEvent(event);
if (flags().testFlag(ItemIsMovable) && moved_)
{
moved_(control_.id, pos());
}
}

private:
QString textWithValue() const
{
const QString text = QString::fromUtf8(
control_.text.data(), static_cast<int>(control_.text.size()));
if (!has_runtime_value_ || control_.type == HmiControlType::Button)
{
return text;
}
if (control_.type == HmiControlType::NumericDisplay
|| control_.type == HmiControlType::NumericInput)
{
return text + QStringLiteral(": ") + QString::number(word_value_);
}
return text;
}

HmiControl control_;
int page_width_ = 0;
int page_height_ = 0;
std::function<void(const std::string &, const QPointF &)> moved_;
std::function<void(const std::string &)> activated_;
bool bit_value_ = false;
std::int16_t word_value_ = 0;
bool has_runtime_value_ = false;
};

HmiGraphicsItem *asHmiItem(QGraphicsItem *item)
{
return dynamic_cast<HmiGraphicsItem *>(item);
}

} // namespace

HmiEditorWidget::HmiEditorWidget(
HmiEditorService &editor_service,
HmiRuntimeService &runtime_service,
QWidget *parent)
: QGraphicsView(parent),
editor_service_(editor_service),
runtime_service_(runtime_service),
scene_(new QGraphicsScene(this))
{
setObjectName(QStringLiteral("hmiEditorWidget"));
setScene(scene_);
setRenderHint(QPainter::Antialiasing, true);
setDragMode(QGraphicsView::RubberBandDrag);
setBackgroundBrush(QColor(QStringLiteral("#dfe5e9")));
connect(scene_, &QGraphicsScene::selectionChanged,
this, &HmiEditorWidget::handleSelectionChanged);
}

void HmiEditorWidget::setPageId(const std::string &page_id)
{
if (page_id_ == page_id)
{
return;
}
page_id_ = page_id;
reloadPage();
}

void HmiEditorWidget::setEditingEnabled(bool enabled)
{
editing_enabled_ = enabled;
setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag);
updateItemInteractions();
}

void HmiEditorWidget::setRuntimeActive(bool active)
{
runtime_active_ = active;
if (!runtime_active_)
{
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr)
{
control_item->setRuntimeValue(false, 0, false);
}
}
}
}

void HmiEditorWidget::reloadPage()
{
scene_->clear();
const HmiPage *page = editor_service_.findPage(page_id_);
if (page == nullptr)
{
scene_->setSceneRect({});
emit controlSelected({});
return;
}

scene_->setSceneRect(0, 0, page->width, page->height);
QGraphicsRectItem *page_border = scene_->addRect(
scene_->sceneRect(), QPen(QColor(QStringLiteral("#8a98a3")), 1), Qt::white);
page_border->setZValue(-1);
page_border->setAcceptedMouseButtons(Qt::NoButton);

for (const HmiControl &control : page->controls)
{
auto *item = new HmiGraphicsItem(
control,
page->width,
page->height,
[this](const std::string &control_id, const QPointF &position)
{
handleControlMoved(control_id, position);
},
[this](const std::string &control_id)
{
handleControlActivated(control_id);
});
scene_->addItem(item);
item->setInteractionEnabled(editing_enabled_);
}
fitCurrentPage();
refreshRuntimeValues();
}

void HmiEditorWidget::selectControl(const std::string &control_id)
{
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr && control_item->controlId() == control_id)
{
control_item->setSelected(true);
ensureVisible(control_item);
return;
}
}
}

std::string HmiEditorWidget::selectedControlId() const
{
const QList<QGraphicsItem *> selected = scene_->selectedItems();
for (QGraphicsItem *item : selected)
{
const HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr)
{
return control_item->controlId();
}
}
return {};
}

void HmiEditorWidget::refreshRuntimeValues()
{
if (!runtime_active_)
{
return;
}
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item == nullptr)
{
continue;
}
const HmiControl *control = editor_service_.findControl(
page_id_, control_item->controlId());
if (control == nullptr)
{
continue;
}
const HmiRuntimeReadResult value = runtime_service_.readControl(*control);
control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded);
}
}

void HmiEditorWidget::resizeEvent(QResizeEvent *event)
{
QGraphicsView::resizeEvent(event);
fitCurrentPage();
}

void HmiEditorWidget::fitCurrentPage()
{
if (scene_->sceneRect().isEmpty())
{
return;
}
fitInView(
scene_->sceneRect().adjusted(-16, -16, 16, 16),
Qt::KeepAspectRatio);
}

void HmiEditorWidget::updateItemInteractions()
{
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr)
{
control_item->setInteractionEnabled(editing_enabled_);
}
}
}

void HmiEditorWidget::handleSelectionChanged()
{
emit controlSelected(QString::fromStdString(selectedControlId()));
}

void HmiEditorWidget::handleControlMoved(
const std::string &control_id, const QPointF &position)
{
const HmiControl *control = editor_service_.findControl(page_id_, control_id);
if (control == nullptr)
{
return;
}
HmiRect bounds = control->bounds;
bounds.x = static_cast<int>(std::lround(position.x()));
bounds.y = static_cast<int>(std::lround(position.y()));
const HmiEditorResult result = editor_service_.moveControl(
page_id_, control_id, bounds);
if (!result.succeeded)
{
emit editorError(QString::fromStdString(result.message));
reloadPage();
return;
}
emit controlChanged(QString::fromStdString(control_id));
}

void HmiEditorWidget::handleControlActivated(const std::string &control_id)
{
if (!runtime_active_)
{
return;
}
const HmiControl *control = editor_service_.findControl(page_id_, control_id);
if (control == nullptr)
{
return;
}
HmiRuntimeWriteResult result;
if (control->type == HmiControlType::Button)
{
result = runtime_service_.toggleButton(*control);
}
else if (control->type == HmiControlType::NumericInput)
{
bool accepted = false;
const HmiRuntimeReadResult current = runtime_service_.readControl(*control);
const int initial = current.succeeded ? current.word_value : 0;
const int value = QInputDialog::getInt(
this,
tr("输入数值"),
QString::fromUtf8(control->text.data(), static_cast<int>(control->text.size())),
initial,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
1,
&accepted);
if (!accepted)
{
return;
}
result = runtime_service_.writeNumericInput(
*control, static_cast<std::int16_t>(value));
}
else
{
return;
}
if (!result.succeeded)
{
emit editorError(tr("寄存器操作失败"));
return;
}
refreshRuntimeValues();
}

+ 107
- 0
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 <QGraphicsView>

#include <string>

class HmiEditorService;
class HmiRuntimeService;
class QGraphicsScene;
class QResizeEvent;

/**
* @brief 将 HMI 页面模型投影为可选择和拖动的图形编辑画布
*
* 图元只保存控件标识,所有工程修改和寄存器访问分别委托给服务层
*/
class HmiEditorWidget final : public QGraphicsView
{
Q_OBJECT

public:
/**
* @brief 创建 HMI 编辑画布
* @param editor_service HMI 编辑服务,调用方必须保证其生命周期覆盖画布
* @param runtime_service HMI 运行服务,调用方必须保证其生命周期覆盖画布
* @param parent Qt 父对象
*/
explicit HmiEditorWidget(
HmiEditorService &editor_service,
HmiRuntimeService &runtime_service,
QWidget *parent = nullptr);

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

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

protected:
void resizeEvent(QResizeEvent *event) override;

private:
void fitCurrentPage();
void updateItemInteractions();
void handleSelectionChanged();
void handleControlMoved(const std::string &control_id, const QPointF &position);
void handleControlActivated(const std::string &control_id);

HmiEditorService &editor_service_;
HmiRuntimeService &runtime_service_;
QGraphicsScene *scene_ = nullptr;
std::string page_id_;
bool editing_enabled_ = true;
bool runtime_active_ = false;
};

+ 645
- 13
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 <QActionGroup>
#include <QApplication>
#include <QComboBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSpinBox>
#include <QStatusBar>
#include <QStyle>
#include <QTimer>
#include <QToolBar>
#include <QTreeWidget>
#include <QTreeWidgetItem>

namespace {

QString modeText(ApplicationMode mode)
{
switch (mode)
{
case ApplicationMode::Editing:
{
return MainWindow::tr("编辑态");
}
case ApplicationMode::OfflineRunning:
{
return MainWindow::tr("离线运行态");
}
case ApplicationMode::OnlineRunning:
{
return MainWindow::tr("真机运行态");
}
default:
{
return MainWindow::tr("未知状态");
}
}
}

QString transitionErrorText(ModeTransitionError error)
{
switch (error)
{
case ModeTransitionError::MustReturnToEditing:
{
return MainWindow::tr("请先返回编辑态,再切换运行模式");
}
case ModeTransitionError::InitialPlcReadRequired:
{
return MainWindow::tr("真机运行前必须连接 PLC 并完成首次读取");
}
case ModeTransitionError::AlreadyInRequestedMode:
{
return MainWindow::tr("当前已处于所选模式");
}
case ModeTransitionError::None:
default:
{
return MainWindow::tr("模式切换失败");
}
}
}

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

QString controlTypeText(HmiControlType type)
{
switch (type)
{
case HmiControlType::Button:
{
return MainWindow::tr("按钮");
}
case HmiControlType::Indicator:
{
return MainWindow::tr("指示灯");
}
case HmiControlType::NumericDisplay:
{
return MainWindow::tr("数值显示");
}
case HmiControlType::NumericInput:
{
return MainWindow::tr("数值输入");
}
case HmiControlType::Label:
default:
{
return MainWindow::tr("文本");
}
}
}

bool usesMAddress(HmiControlType type)
{
return type == HmiControlType::Button || type == HmiControlType::Indicator;
}

} // namespace

MainWindow::MainWindow(QWidget *parent)
MainWindow::MainWindow(
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service,
QWidget *parent)
: QMainWindow(parent),
ui_(std::make_unique<Ui::MainWindow>())
ui_(std::make_unique<Ui::MainWindow>()),
runtime_mode_service_(runtime_mode_service),
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
hmi_runtime_service_(hmi_runtime_service)
{
ui_->setupUi(this);
statusBar()->showMessage(tr("开发基线已建立"));
configureAppearance();
configureActions();
configurePropertyEditor();
configureHmiEditor();
hmi_editor_service_.ensureDefaultPage();
refreshProjectUi();
updateModeUi(tr("系统已进入编辑态"));
}

MainWindow::~MainWindow() = default;

} // namespace integrated_platform
void MainWindow::configureActions()
{
mode_action_group_ = new QActionGroup(this);
mode_action_group_->setExclusive(true);
mode_action_group_->addAction(ui_->editingModeAction);
mode_action_group_->addAction(ui_->offlineModeAction);
mode_action_group_->addAction(ui_->onlineModeAction);

connect(ui_->editingModeAction, &QAction::triggered, this,
[this] { requestMode(ApplicationMode::Editing); });
connect(ui_->offlineModeAction, &QAction::triggered, this,
[this] { requestMode(ApplicationMode::OfflineRunning); });
connect(ui_->onlineModeAction, &QAction::triggered, this,
[this] { requestMode(ApplicationMode::OnlineRunning); });
connect(ui_->exitAction, &QAction::triggered, qApp, &QApplication::closeAllWindows);

new_project_action_ = new QAction(tr("新建工程"), this);
save_project_action_ = new QAction(tr("保存工程"), this);
save_as_project_action_ = new QAction(tr("工程另存为"), this);
load_project_action_ = new QAction(tr("加载工程"), this);
new_project_action_->setObjectName(QStringLiteral("newProjectAction"));
save_project_action_->setObjectName(QStringLiteral("saveProjectAction"));
save_as_project_action_->setObjectName(QStringLiteral("saveAsProjectAction"));
load_project_action_->setObjectName(QStringLiteral("loadProjectAction"));
new_project_action_->setIcon(style()->standardIcon(QStyle::SP_FileIcon));
save_project_action_->setIcon(style()->standardIcon(QStyle::SP_DialogSaveButton));
load_project_action_->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton));
ui_->fileMenu->insertAction(ui_->exitAction, new_project_action_);
ui_->fileMenu->insertAction(ui_->exitAction, save_project_action_);
ui_->fileMenu->insertAction(ui_->exitAction, save_as_project_action_);
ui_->fileMenu->insertAction(ui_->exitAction, load_project_action_);
ui_->fileMenu->insertSeparator(ui_->exitAction);
connect(new_project_action_, &QAction::triggered, this, &MainWindow::createNewProject);
connect(save_project_action_, &QAction::triggered, this, &MainWindow::saveProject);
connect(save_as_project_action_, &QAction::triggered, this, &MainWindow::saveProjectAs);
connect(load_project_action_, &QAction::triggered, this, &MainWindow::loadProject);

hmi_tool_bar_ = addToolBar(tr("HMI 控件"));
hmi_tool_bar_->setObjectName(QStringLiteral("hmiToolBar"));
hmi_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
add_button_action_ = hmi_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_DialogApplyButton), tr("按钮"));
add_indicator_action_ = hmi_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_DialogYesButton), tr("指示灯"));
add_numeric_display_action_ = hmi_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("数值显示"));
add_numeric_input_action_ = hmi_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_FileDialogContentsView), tr("数值输入"));
delete_control_action_ = hmi_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_TrashIcon), tr("删除"));
add_button_action_->setObjectName(QStringLiteral("addButtonAction"));
add_indicator_action_->setObjectName(QStringLiteral("addIndicatorAction"));
add_numeric_display_action_->setObjectName(QStringLiteral("addNumericDisplayAction"));
add_numeric_input_action_->setObjectName(QStringLiteral("addNumericInputAction"));
delete_control_action_->setObjectName(QStringLiteral("deleteControlAction"));
add_button_action_->setToolTip(tr("添加按钮控件"));
add_indicator_action_->setToolTip(tr("添加指示灯控件"));
add_numeric_display_action_->setToolTip(tr("添加数值显示控件"));
add_numeric_input_action_->setToolTip(tr("添加数值输入控件"));
delete_control_action_->setToolTip(tr("删除当前控件"));
connect(add_button_action_, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::Button); });
connect(add_indicator_action_, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::Indicator); });
connect(add_numeric_display_action_, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::NumericDisplay); });
connect(add_numeric_input_action_, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::NumericInput); });
connect(delete_control_action_, &QAction::triggered,
this, &MainWindow::deleteSelectedControl);

ui_->viewMenu->addAction(ui_->projectDock->toggleViewAction());
ui_->viewMenu->addAction(ui_->propertiesDock->toggleViewAction());
ui_->viewMenu->addAction(ui_->outputDock->toggleViewAction());
ui_->viewMenu->addAction(hmi_tool_bar_->toggleViewAction());
ui_->viewMenu->addSeparator();
ui_->viewMenu->addAction(ui_->modeToolBar->toggleViewAction());
}

void MainWindow::configureAppearance()
{
setDockNestingEnabled(true);
setCorner(Qt::BottomLeftCorner, Qt::LeftDockWidgetArea);
setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea);

ui_->editingModeAction->setIcon(style()->standardIcon(QStyle::SP_FileDialogDetailedView));
ui_->offlineModeAction->setIcon(style()->standardIcon(QStyle::SP_MediaPlay));
ui_->onlineModeAction->setIcon(style()->standardIcon(QStyle::SP_DriveNetIcon));
ui_->editorTabWidget->setTabIcon(0, style()->standardIcon(QStyle::SP_DesktopIcon));
ui_->editorTabWidget->setTabIcon(1, style()->standardIcon(QStyle::SP_FileDialogListView));
ui_->outputDock->setMaximumHeight(220);

mode_status_label_ = new QLabel(this);
mode_status_label_->setObjectName(QStringLiteral("modeStatusLabel"));
mode_status_label_->setMinimumWidth(88);
mode_status_label_->setAlignment(Qt::AlignCenter);
register_status_label_ = new QLabel(this);
register_status_label_->setMinimumWidth(132);
executor_status_label_ = new QLabel(this);
executor_status_label_->setMinimumWidth(132);
statusBar()->addPermanentWidget(mode_status_label_);
statusBar()->addPermanentWidget(register_status_label_);
statusBar()->addPermanentWidget(executor_status_label_);

setStyleSheet(QStringLiteral(
"QMainWindow { background: #f3f5f7; }"
"QToolBar { background: #ffffff; border: 0; border-bottom: 1px solid #cbd2d9;"
" padding: 4px; spacing: 3px; }"
"QToolButton { min-height: 26px; padding: 3px 9px; border: 1px solid transparent; }"
"QToolButton:hover { background: #edf2f6; border-color: #c5ced6; }"
"QToolButton:checked { background: #dcece3; border-color: #75a58a; }"
"QDockWidget { color: #24313b; font-weight: 600; }"
"QDockWidget::title { background: #e9edf0; padding: 6px; border-bottom: 1px solid #cbd2d9; }"
"QTreeWidget, QListWidget, QScrollArea { background: #ffffff; border: 1px solid #d5dbe0; }"
"QTabWidget::pane { border: 0; background: #f3f5f7; }"
"QTabBar::tab { background: #e5e9ec; padding: 7px 14px; border-right: 1px solid #cbd2d9; }"
"QTabBar::tab:selected { background: #ffffff; color: #15232d; }"
"QFrame#hmiCanvasPlaceholder, QFrame#logicCanvasPlaceholder { background: #ffffff;"
" border: 1px solid #cbd2d9; }"
"QLabel#hmiEmptyLabel, QLabel#logicEmptyLabel { color: #75838d; }"
"QLabel#hmiPageTitleLabel { color: #1d2a33; font-weight: 600; }"
"QLabel#hmiPageSizeLabel { color: #66747e; }"));
}

void MainWindow::configureHmiEditor()
{
QLayout *layout = ui_->hmiCanvasPlaceholder->layout();
delete ui_->hmiEmptyLabel;
hmi_editor_widget_ = new HmiEditorWidget(
hmi_editor_service_, hmi_runtime_service_, ui_->hmiCanvasPlaceholder);
hmi_editor_widget_->setMinimumHeight(320);
layout->addWidget(hmi_editor_widget_);
connect(hmi_editor_widget_, &HmiEditorWidget::controlSelected,
this,
[this](const QString &id) { showControlProperties(toUtf8(id)); });
connect(hmi_editor_widget_, &HmiEditorWidget::controlChanged,
this,
[this](const QString &id)
{
showControlProperties(toUtf8(id));
refreshProjectUi();
});
connect(hmi_editor_widget_, &HmiEditorWidget::editorError,
this,
[this](const QString &message) { statusBar()->showMessage(message, 5000); });
runtime_refresh_timer_ = new QTimer(this);
runtime_refresh_timer_->setInterval(150);
connect(runtime_refresh_timer_, &QTimer::timeout,
this,
[this]
{
if (runtime_mode_service_.mode() != ApplicationMode::Editing)
{
hmi_editor_widget_->refreshRuntimeValues();
}
});
runtime_refresh_timer_->start();
}

void MainWindow::configurePropertyEditor()
{
control_id_edit_ = new QLineEdit(ui_->propertiesPage);
control_id_edit_->setObjectName(QStringLiteral("controlIdEdit"));
control_text_edit_ = new QLineEdit(ui_->propertiesPage);
control_text_edit_->setObjectName(QStringLiteral("controlTextEdit"));
control_x_spin_box_ = new QSpinBox(ui_->propertiesPage);
control_y_spin_box_ = new QSpinBox(ui_->propertiesPage);
control_width_spin_box_ = new QSpinBox(ui_->propertiesPage);
control_height_spin_box_ = new QSpinBox(ui_->propertiesPage);
control_x_spin_box_->setObjectName(QStringLiteral("controlXSpinBox"));
control_y_spin_box_->setObjectName(QStringLiteral("controlYSpinBox"));
control_width_spin_box_->setObjectName(QStringLiteral("controlWidthSpinBox"));
control_height_spin_box_->setObjectName(QStringLiteral("controlHeightSpinBox"));
for (QSpinBox *box : {control_x_spin_box_, control_y_spin_box_})
{
box->setRange(0, 4000);
}
for (QSpinBox *box : {control_width_spin_box_, control_height_spin_box_})
{
box->setRange(1, 4000);
}
binding_area_combo_box_ = new QComboBox(ui_->propertiesPage);
binding_area_combo_box_->setObjectName(QStringLiteral("bindingAreaComboBox"));
binding_area_combo_box_->addItem(QStringLiteral("M"), QVariant::fromValue(0));
binding_area_combo_box_->addItem(QStringLiteral("D"), QVariant::fromValue(1));
binding_index_spin_box_ = new QSpinBox(ui_->propertiesPage);
binding_index_spin_box_->setObjectName(QStringLiteral("bindingIndexSpinBox"));
binding_index_spin_box_->setRange(0, RegisterAddress::kMaximumIndex);
apply_properties_button_ = new QPushButton(tr("应用属性"), ui_->propertiesPage);
apply_properties_button_->setObjectName(QStringLiteral("applyPropertiesButton"));
QFormLayout *form = ui_->propertiesForm;
form->addRow(tr("控件 ID"), control_id_edit_);
form->addRow(tr("显示文本"), control_text_edit_);
form->addRow(tr("X"), control_x_spin_box_);
form->addRow(tr("Y"), control_y_spin_box_);
form->addRow(tr("宽度"), control_width_spin_box_);
form->addRow(tr("高度"), control_height_spin_box_);
form->addRow(tr("绑定区域"), binding_area_combo_box_);
form->addRow(tr("绑定地址"), binding_index_spin_box_);
form->addRow(apply_properties_button_);
connect(apply_properties_button_, &QPushButton::clicked,
this, &MainWindow::applySelectedControlProperties);
showControlProperties({});
}

void MainWindow::refreshProjectUi()
{
const std::string current_page_id = hmi_editor_service_.firstPageId();
if (hmi_editor_widget_ != nullptr)
{
hmi_editor_widget_->setPageId(current_page_id);
}
const HmiPage *page = hmi_editor_service_.findPage(current_page_id);
ui_->projectTree->clear();
auto *hmi_root = new QTreeWidgetItem(ui_->projectTree, {tr("HMI 页面")});
if (page != nullptr)
{
hmi_root->addChild(new QTreeWidgetItem(hmi_root, {fromUtf8(page->name)}));
ui_->hmiPageTitleLabel->setText(fromUtf8(page->name));
ui_->hmiPageSizeLabel->setText(
tr("%1 x %2").arg(page->width).arg(page->height));
}
else
{
ui_->hmiPageTitleLabel->setText(tr("尚未创建 HMI 页面"));
ui_->hmiPageSizeLabel->clear();
}
new QTreeWidgetItem(ui_->projectTree, {tr("控制逻辑")});
ui_->projectTree->expandAll();
}

void MainWindow::showControlProperties(const std::string &control_id)
{
selected_control_id_ = control_id;
const HmiControl *control = hmi_editor_service_.findControl(
hmi_editor_service_.firstPageId(), control_id);
const bool has_control = control != nullptr;
ui_->selectionValueLabel->setText(
has_control ? fromUtf8(control->id) : tr("未选择"));
for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_),
static_cast<QWidget *>(control_text_edit_),
static_cast<QWidget *>(control_x_spin_box_),
static_cast<QWidget *>(control_y_spin_box_),
static_cast<QWidget *>(control_width_spin_box_),
static_cast<QWidget *>(control_height_spin_box_),
static_cast<QWidget *>(binding_area_combo_box_),
static_cast<QWidget *>(binding_index_spin_box_),
static_cast<QWidget *>(apply_properties_button_)})
{
widget->setEnabled(has_control);
}
if (!has_control)
{
return;
}
control_id_edit_->setText(fromUtf8(control->id));
control_text_edit_->setText(fromUtf8(control->text));
control_x_spin_box_->setValue(control->bounds.x);
control_y_spin_box_->setValue(control->bounds.y);
control_width_spin_box_->setValue(control->bounds.width);
control_height_spin_box_->setValue(control->bounds.height);
const RegisterArea default_area = usesMAddress(control->type)
? RegisterArea::M : RegisterArea::D;
const RegisterArea area = control->binding.has_value()
? control->binding->area() : default_area;
binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1);
binding_index_spin_box_->setValue(
control->binding.has_value() ? control->binding->index() : 0);
}

void MainWindow::addHmiControl(HmiControlType type)
{
const std::string page_id = hmi_editor_service_.firstPageId();
const HmiEditorResult result = hmi_editor_service_.addControl(page_id, type);
if (!result.succeeded)
{
showProjectResult(tr("添加控件"), fromUtf8(result.message), false);
return;
}
hmi_editor_widget_->reloadPage();
hmi_editor_widget_->selectControl(result.id);
showControlProperties(result.id);
refreshProjectUi();
statusBar()->showMessage(tr("已添加%1").arg(controlTypeText(type)), 3000);
}

void MainWindow::deleteSelectedControl()
{
if (selected_control_id_.empty())
{
return;
}
const HmiEditorResult result = hmi_editor_service_.removeControl(
hmi_editor_service_.firstPageId(), selected_control_id_);
if (!result.succeeded)
{
showProjectResult(tr("删除控件"), fromUtf8(result.message), false);
return;
}
selected_control_id_.clear();
hmi_editor_widget_->reloadPage();
showControlProperties({});
refreshProjectUi();
}

void MainWindow::applySelectedControlProperties()
{
const HmiControl *old_control = hmi_editor_service_.findControl(
hmi_editor_service_.firstPageId(), selected_control_id_);
if (old_control == nullptr)
{
return;
}
HmiControl control = *old_control;
control.id = toUtf8(control_id_edit_->text());
control.text = toUtf8(control_text_edit_->text());
control.bounds.x = control_x_spin_box_->value();
control.bounds.y = control_y_spin_box_->value();
control.bounds.width = control_width_spin_box_->value();
control.bounds.height = control_height_spin_box_->value();
const RegisterArea area = binding_area_combo_box_->currentIndex() == 0
? RegisterArea::M : RegisterArea::D;
control.binding = RegisterAddress{area, binding_index_spin_box_->value()};
const HmiEditorResult result = hmi_editor_service_.updateControl(
hmi_editor_service_.firstPageId(), selected_control_id_, control);
if (!result.succeeded)
{
showProjectResult(tr("应用属性"), fromUtf8(result.message), false);
return;
}
selected_control_id_ = result.id;
hmi_editor_widget_->reloadPage();
hmi_editor_widget_->selectControl(selected_control_id_);
refreshProjectUi();
statusBar()->showMessage(tr("控件属性已更新"), 3000);
}

void MainWindow::createNewProject()
{
const QString name = QInputDialog::getText(
this, tr("新建工程"), tr("工程名称"));
if (name.isEmpty())
{
return;
}
const ProjectOperationResult result = project_service_.createNewProject(toUtf8(name));
if (!result.succeeded)
{
showProjectResult(tr("新建工程"), fromUtf8(result.message), false);
return;
}
hmi_editor_service_.ensureDefaultPage();
selected_control_id_.clear();
refreshProjectUi();
hmi_editor_widget_->reloadPage();
showControlProperties({});
statusBar()->showMessage(tr("已创建新工程"), 3000);
}

void MainWindow::saveProject()
{
const ProjectOperationResult result = project_service_.save();
if (!result.succeeded)
{
saveProjectAs();
return;
}
showProjectResult(tr("保存工程"), tr("工程已保存"), true);
}

void MainWindow::saveProjectAs()
{
const QString path = QFileDialog::getSaveFileName(
this, tr("工程另存为"), {}, tr("工程文件 (*.json)"));
if (path.isEmpty())
{
return;
}
const ProjectOperationResult result = project_service_.saveAs(toUtf8(path));
showProjectResult(
tr("保存工程"),
result.succeeded ? tr("工程已保存") : fromUtf8(result.message),
result.succeeded);
}

void MainWindow::loadProject()
{
const QString path = QFileDialog::getOpenFileName(
this, tr("加载工程"), {}, tr("工程文件 (*.json)"));
if (path.isEmpty())
{
return;
}
const ProjectOperationResult result = project_service_.load(toUtf8(path));
if (!result.succeeded)
{
showProjectResult(tr("加载工程"), fromUtf8(result.message), false);
return;
}
selected_control_id_.clear();
refreshProjectUi();
hmi_editor_widget_->reloadPage();
showControlProperties({});
showProjectResult(tr("加载工程"), tr("工程已加载"), true);
}

void MainWindow::showProjectResult(
const QString &action, const QString &message, bool succeeded)
{
const QString output = action + QStringLiteral(": ") + message;
statusBar()->showMessage(output, 5000);
ui_->outputList->addItem(output);
ui_->outputList->scrollToBottom();
if (!succeeded)
{
QMessageBox::warning(this, action, message);
}
}

void MainWindow::requestMode(ApplicationMode requested_mode)
{
ModeTransitionResult result;
switch (requested_mode)
{
case ApplicationMode::Editing:
{
result = runtime_mode_service_.enterEditing();
break;
}
case ApplicationMode::OfflineRunning:
{
result = runtime_mode_service_.enterOfflineRunning();
break;
}
case ApplicationMode::OnlineRunning:
{
result = runtime_mode_service_.enterOnlineRunning();
break;
}
default:
{
restoreCurrentModeAction();
statusBar()->showMessage(tr("不支持的运行模式"), 5000);
return;
}
}
if (!result.succeeded)
{
restoreCurrentModeAction();
statusBar()->showMessage(transitionErrorText(result.error), 5000);
return;
}
updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode())));
}

void MainWindow::updateModeUi(const QString &message)
{
const ApplicationMode mode = runtime_mode_service_.mode();
const ModePolicy policy = runtime_mode_service_.policy();
restoreCurrentModeAction();
ui_->projectDock->setEnabled(policy.allowsProjectEditing);
ui_->propertiesDock->setEnabled(policy.allowsProjectEditing);
hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing);
hmi_editor_widget_->setRuntimeActive(policy.usesVirtualRegisters);
hmi_tool_bar_->setEnabled(policy.allowsProjectEditing);
add_button_action_->setEnabled(policy.allowsProjectEditing);
add_indicator_action_->setEnabled(policy.allowsProjectEditing);
add_numeric_display_action_->setEnabled(policy.allowsProjectEditing);
add_numeric_input_action_->setEnabled(policy.allowsProjectEditing);
delete_control_action_->setEnabled(policy.allowsProjectEditing);
new_project_action_->setEnabled(policy.allowsProjectEditing);
save_project_action_->setEnabled(policy.allowsProjectEditing);
save_as_project_action_->setEnabled(policy.allowsProjectEditing);
load_project_action_->setEnabled(policy.allowsProjectEditing);

mode_status_label_->setText(modeText(mode));
if (mode == ApplicationMode::Editing)
{
mode_status_label_->setStyleSheet(QStringLiteral(
"border: 1px solid #75a58a; background: #e3f0e8; color: #205c3b; padding: 2px 8px;"));
}
else if (mode == ApplicationMode::OfflineRunning)
{
mode_status_label_->setStyleSheet(QStringLiteral(
"border: 1px solid #bd8739; background: #fff0d2; color: #744b0e; padding: 2px 8px;"));
}
else
{
mode_status_label_->setStyleSheet(QStringLiteral(
"border: 1px solid #4b88a8; background: #deeff7; color: #174f6c; padding: 2px 8px;"));
}
register_status_label_->setText(
policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D")
: policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用"));
executor_status_label_->setText(
policy.runsLogicExecutor ? tr("逻辑执行器:运行") : tr("逻辑执行器:停止"));
statusBar()->showMessage(message, 4000);
ui_->outputList->addItem(message);
ui_->outputList->scrollToBottom();
}

void MainWindow::restoreCurrentModeAction()
{
ui_->editingModeAction->setChecked(runtime_mode_service_.mode()
== ApplicationMode::Editing);
ui_->offlineModeAction->setChecked(runtime_mode_service_.mode()
== ApplicationMode::OfflineRunning);
ui_->onlineModeAction->setChecked(runtime_mode_service_.mode()
== ApplicationMode::OnlineRunning);
}

+ 110
- 7
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 <QMainWindow>

#include <memory>

QT_BEGIN_NAMESPACE
class QActionGroup;
class QAction;
class QComboBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QTimer;
class QToolBar;

namespace Ui {
class MainWindow;
}
QT_END_NAMESPACE

namespace integrated_platform {
class RuntimeModeService;
class ProjectService;
class HmiEditorService;
class HmiRuntimeService;
class HmiEditorWidget;

/**
* @brief 组织主界面区域并将用户操作转交给服务层
*
* 主窗口不直接访问寄存器、工程文件或 PLC 通信实现
*/
class MainWindow final : public QMainWindow
{
Q_OBJECT

public:
explicit MainWindow(QWidget *parent = nullptr);
/**
* @brief 创建主窗口并注入 UI 所需的应用服务
* @param runtime_mode_service 运行模式服务,调用方必须保证其生命周期覆盖窗口
* @param project_service 工程管理服务,调用方必须保证其生命周期覆盖窗口
* @param hmi_editor_service HMI 编辑服务,调用方必须保证其生命周期覆盖窗口
* @param hmi_runtime_service HMI 运行服务,调用方必须保证其生命周期覆盖窗口
* @param parent Qt 父对象
*/
explicit MainWindow(
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service,
QWidget *parent = nullptr);

~MainWindow() override;

private:
void configureActions();
void configureAppearance();
void configureHmiEditor();
void configurePropertyEditor();
void refreshProjectUi();
void showControlProperties(const std::string &control_id);
void addHmiControl(HmiControlType type);
void deleteSelectedControl();
void applySelectedControlProperties();
void createNewProject();
void saveProject();
void saveProjectAs();
void loadProject();
void showProjectResult(const QString &action, const QString &message, bool succeeded);

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

/**
* @brief 根据服务层模式策略更新编辑权限和状态反馈
* @param message 要显示在状态栏和输出区的模式结果消息
*/
void updateModeUi(const QString &message);

/**
* @brief 将互斥模式动作同步到服务层当前状态
*/
void restoreCurrentModeAction();

std::unique_ptr<Ui::MainWindow> ui_;
/**
* @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期
*/
RuntimeModeService &runtime_mode_service_;
/**
* @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
HmiEditorService &hmi_editor_service_;
HmiRuntimeService &hmi_runtime_service_;
QActionGroup *mode_action_group_ = nullptr;
QAction *new_project_action_ = nullptr;
QAction *save_project_action_ = nullptr;
QAction *save_as_project_action_ = nullptr;
QAction *load_project_action_ = nullptr;
QAction *add_button_action_ = nullptr;
QAction *add_indicator_action_ = nullptr;
QAction *add_numeric_display_action_ = nullptr;
QAction *add_numeric_input_action_ = nullptr;
QAction *delete_control_action_ = nullptr;
QToolBar *hmi_tool_bar_ = nullptr;
HmiEditorWidget *hmi_editor_widget_ = nullptr;
QTimer *runtime_refresh_timer_ = nullptr;
QLabel *mode_status_label_ = nullptr;
QLabel *register_status_label_ = nullptr;
QLabel *executor_status_label_ = nullptr;
QLineEdit *control_id_edit_ = nullptr;
QLineEdit *control_text_edit_ = nullptr;
QSpinBox *control_x_spin_box_ = nullptr;
QSpinBox *control_y_spin_box_ = nullptr;
QSpinBox *control_width_spin_box_ = nullptr;
QSpinBox *control_height_spin_box_ = nullptr;
QComboBox *binding_area_combo_box_ = nullptr;
QSpinBox *binding_index_spin_box_ = nullptr;
QPushButton *apply_properties_button_ = nullptr;
std::string selected_control_id_;
};

} // namespace integrated_platform

+ 445
- 51
app/src/ui/main_window.ui 파일 보기

@@ -6,72 +6,466 @@
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>800</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>960</width>
<height>640</height>
</rect>
</size>
</property>
<property name="windowTitle">
<property name="windowTitle">
<string>综合平台编程器</string>
</property>
<property name="dockOptions">
<set>QMainWindow::AllowNestedDocks|QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks</set>
</property>
<widget class="QWidget" name="centralWidget">
<layout class="QVBoxLayout" name="verticalLayout">
<layout class="QVBoxLayout" name="centralLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="topSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>180</height>
</size>
<widget class="QTabWidget" name="editorTabWidget">
<property name="documentMode">
<bool>true</bool>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="titleLabel">
<property name="font">
<font>
<pointsize>20</pointsize>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>综合平台编程器</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="statusLabel">
<property name="text">
<string>Qt Widgets 开发基线已建立</string>
<property name="tabsClosable">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
<property name="movable">
<bool>false</bool>
</property>
<widget class="QWidget" name="hmiEditorTab">
<attribute name="title">
<string>HMI 页面</string>
</attribute>
<layout class="QVBoxLayout" name="hmiEditorLayout">
<property name="spacing">
<number>6</number>
</property>
<property name="leftMargin">
<number>8</number>
</property>
<property name="topMargin">
<number>8</number>
</property>
<property name="rightMargin">
<number>8</number>
</property>
<property name="bottomMargin">
<number>8</number>
</property>
<item>
<widget class="QFrame" name="hmiPageHeader">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>28</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>28</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<layout class="QHBoxLayout" name="hmiPageHeaderLayout">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="hmiPageTitleLabel">
<property name="text">
<string>主操作页面</string>
</property>
</widget>
</item>
<item>
<spacer name="hmiHeaderSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="hmiPageSizeLabel">
<property name="text">
<string>800 x 480</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="hmiCanvasPlaceholder">
<property name="minimumSize">
<size>
<width>0</width>
<height>320</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<layout class="QVBoxLayout" name="hmiCanvasLayout">
<item>
<widget class="QLabel" name="hmiEmptyLabel">
<property name="text">
<string>尚未创建 HMI 页面</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="logicEditorTab">
<attribute name="title">
<string>控制逻辑</string>
</attribute>
<layout class="QVBoxLayout" name="logicEditorLayout">
<property name="leftMargin">
<number>12</number>
</property>
<property name="topMargin">
<number>12</number>
</property>
<property name="rightMargin">
<number>12</number>
</property>
<property name="bottomMargin">
<number>12</number>
</property>
<item>
<widget class="QFrame" name="logicCanvasPlaceholder">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<layout class="QVBoxLayout" name="logicCanvasLayout">
<item>
<widget class="QLabel" name="logicEmptyLabel">
<property name="text">
<string>尚未创建控制逻辑</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<spacer name="bottomSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>180</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menuBar"/>
<widget class="QMenuBar" name="menuBar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>23</height>
</rect>
</property>
<widget class="QMenu" name="fileMenu">
<property name="title">
<string>文件(&amp;F)</string>
</property>
<addaction name="exitAction"/>
</widget>
<widget class="QMenu" name="runMenu">
<property name="title">
<string>运行(&amp;R)</string>
</property>
<addaction name="editingModeAction"/>
<addaction name="offlineModeAction"/>
<addaction name="onlineModeAction"/>
</widget>
<widget class="QMenu" name="viewMenu">
<property name="title">
<string>视图(&amp;V)</string>
</property>
</widget>
<addaction name="fileMenu"/>
<addaction name="runMenu"/>
<addaction name="viewMenu"/>
</widget>
<widget class="QStatusBar" name="statusBar"/>
<widget class="QToolBar" name="modeToolBar">
<property name="windowTitle">
<string>运行模式</string>
</property>
<property name="movable">
<bool>false</bool>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextBesideIcon</enum>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="editingModeAction"/>
<addaction name="offlineModeAction"/>
<addaction name="onlineModeAction"/>
</widget>
<widget class="QDockWidget" name="projectDock">
<property name="minimumSize">
<size>
<width>210</width>
<height>180</height>
</size>
</property>
<property name="windowTitle">
<string>工程</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="projectDockContents">
<layout class="QVBoxLayout" name="projectDockLayout">
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<item>
<widget class="QTreeWidget" name="projectTree">
<property name="headerHidden">
<bool>true</bool>
</property>
<property name="rootIsDecorated">
<bool>true</bool>
</property>
<column>
<property name="text">
<string notr="true">1</string>
</property>
</column>
<item>
<property name="text">
<string>HMI 页面</string>
</property>
</item>
<item>
<property name="text">
<string>控制逻辑</string>
</property>
</item>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="propertiesDock">
<property name="minimumSize">
<size>
<width>250</width>
<height>180</height>
</size>
</property>
<property name="windowTitle">
<string>属性</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="propertiesDockContents">
<layout class="QVBoxLayout" name="propertiesDockLayout">
<property name="leftMargin">
<number>8</number>
</property>
<property name="topMargin">
<number>8</number>
</property>
<property name="rightMargin">
<number>8</number>
</property>
<property name="bottomMargin">
<number>8</number>
</property>
<item>
<widget class="QScrollArea" name="propertiesScrollArea">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="propertiesPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>232</width>
<height>164</height>
</rect>
</property>
<layout class="QFormLayout" name="propertiesForm">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="selectionCaptionLabel">
<property name="text">
<string>当前对象</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="selectionValueLabel">
<property name="text">
<string>未选择</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="outputDock">
<property name="minimumSize">
<size>
<width>300</width>
<height>110</height>
</size>
</property>
<property name="windowTitle">
<string>输出</string>
</property>
<attribute name="dockWidgetArea">
<number>8</number>
</attribute>
<widget class="QWidget" name="outputDockContents">
<layout class="QVBoxLayout" name="outputDockLayout">
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<item>
<widget class="QListWidget" name="outputList">
<item>
<property name="text">
<string>系统已就绪</string>
</property>
</item>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="exitAction">
<property name="text">
<string>退出(&amp;X)</string>
</property>
</action>
<action name="editingModeAction">
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="text">
<string>编辑</string>
</property>
<property name="toolTip">
<string>进入编辑态</string>
</property>
</action>
<action name="offlineModeAction">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>离线运行</string>
</property>
<property name="toolTip">
<string>进入离线运行态</string>
</property>
</action>
<action name="onlineModeAction">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>真机运行</string>
</property>
<property name="toolTip">
<string>进入真机运行态</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>
</ui>

+ 53
- 43
app/tests/domain_tests.cpp 파일 보기

@@ -11,8 +11,6 @@
#include <stdexcept>
#include <string>

namespace domain = integrated_platform::domain;

namespace {

void require(bool condition, const std::string &message)
@@ -25,67 +23,67 @@ void require(bool condition, const std::string &message)

void testRegisterAddressBoundaries()
{
require(domain::RegisterAddress{domain::RegisterArea::M, 0}.isValid(),
require(RegisterAddress{RegisterArea::M, 0}.isValid(),
"M0 must be valid");
require(domain::RegisterAddress{domain::RegisterArea::D, 4000}.isValid(),
require(RegisterAddress{RegisterArea::D, 4000}.isValid(),
"D4000 must be valid");
require(!(domain::RegisterAddress{domain::RegisterArea::M, -1}.isValid()),
require(!(RegisterAddress{RegisterArea::M, -1}.isValid()),
"negative register index must be rejected");
require(!(domain::RegisterAddress{domain::RegisterArea::D, 4001}.isValid()),
require(!(RegisterAddress{RegisterArea::D, 4001}.isValid()),
"register index above 4000 must be rejected");
require(!(domain::RegisterAddress{static_cast<domain::RegisterArea>(99), 0}.isValid()),
require(!(RegisterAddress{static_cast<RegisterArea>(99), 0}.isValid()),
"unknown register area must be rejected");
}

void testRegisterRepositorySeparatesAreas()
{
domain::VirtualRegisterRepository repository;
const domain::RegisterAddress m0{domain::RegisterArea::M, 0};
const domain::RegisterAddress d0{domain::RegisterArea::D, 0};
VirtualRegisterRepository repository;
const RegisterAddress m0{RegisterArea::M, 0};
const RegisterAddress d0{RegisterArea::D, 0};

require(repository.writeBit(m0, true).succeeded, "M bit write must succeed");
require(repository.readBit(m0).value, "M bit read must return written value");
require(repository.writeWord(d0, static_cast<std::int16_t>(-123)).succeeded,
"D word write must succeed");
require(repository.readWord(d0).value == -123, "D word read must return written value");
require(repository.readBit(d0).error == domain::RegisterError::AreaMismatch,
require(repository.readBit(d0).error == RegisterError::AreaMismatch,
"D address must not be read as a bit");
require(repository.readWord(m0).error == domain::RegisterError::AreaMismatch,
require(repository.readWord(m0).error == RegisterError::AreaMismatch,
"M address must not be read as a word");
}

domain::Project makeValidProject()
Project makeValidProject()
{
domain::HmiControl start_button;
HmiControl start_button;
start_button.id = "start-button";
start_button.type = domain::HmiControlType::Button;
start_button.type = HmiControlType::Button;
start_button.text = "Start";
start_button.binding = {domain::RegisterArea::M, 0};
start_button.binding = {RegisterArea::M, 0};

domain::HmiPage page;
HmiPage page;
page.id = "main-page";
page.name = "Main";
page.controls.push_back(start_button);

domain::LogicNode contact;
LogicNode contact;
contact.id = "start-contact";
contact.config = domain::ContactNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 0},
domain::ContactMode::NormallyOpen};
contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};

domain::LogicNode coil;
LogicNode coil;
coil.id = "run-coil";
coil.config = domain::CoilNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 1},
domain::CoilMode::Normal};
coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1},
CoilMode::Normal};

domain::ControlLogic logic;
ControlLogic logic;
logic.id = "start-logic";
logic.name = "Start logic";
logic.nodes = {contact, coil};
logic.connections.push_back({contact.id, coil.id});

domain::Project project;
Project project;
project.metadata = {"sample-project", "Sample project", "1.0"};
project.hmiPages.push_back(page);
project.controlLogics.push_back(logic);
@@ -94,55 +92,67 @@ domain::Project makeValidProject()

void testLogicNodeConfigurationBoundaries()
{
domain::LogicNode contact;
LogicNode contact;
contact.id = "contact";
contact.config = domain::ContactNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 0},
domain::ContactMode::NormallyOpen};
contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};
require(contact.validate(), "contact node bound to M address must be valid");

contact.config = domain::ContactNodeConfig{
domain::RegisterAddress{domain::RegisterArea::D, 0},
domain::ContactMode::NormallyOpen};
contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::D, 0},
ContactMode::NormallyOpen};
require(!contact.validate(), "contact node bound to D address must be rejected");

domain::LogicNode comparison;
LogicNode comparison;
comparison.id = "comparison";
comparison.config = domain::CompareNodeConfig{
domain::RegisterAddress{domain::RegisterArea::D, 0},
domain::ComparisonOperator::GreaterThan,
comparison.config = CompareNodeConfig{
RegisterAddress{RegisterArea::D, 0},
ComparisonOperator::GreaterThan,
static_cast<std::int16_t>(100)};
require(comparison.validate(), "comparison node bound to D address must be valid");
}

void testModelsValidateBindingsAndIdentifiers()
{
domain::Project project = makeValidProject();
Project project = makeValidProject();
require(project.validate(), "valid project model must pass validation");

project.hmiPages.front().controls.front().binding =
domain::RegisterAddress{domain::RegisterArea::D, 0};
RegisterAddress{RegisterArea::D, 0};
require(!project.validate(), "button bound to D area must be rejected");

project = makeValidProject();
project.hmiPages.push_back(project.hmiPages.front());
require(!project.validate(), "duplicate HMI page id must be rejected");

project = makeValidProject();
project.hmiPages.front().controls.front().bounds.x = -1;
require(!project.validate(), "controls outside the page must be rejected");

project = makeValidProject();
project.hmiPages.front().controls.front().bounds.width = 801;
require(!project.validate(), "controls wider than the page must be rejected");

project = makeValidProject();
project.hmiPages.front().controls.front().properties.emplace("", "value");
require(!project.validate(), "empty HMI property names must be rejected");
}

void testRuntimeStateBoundaries()
{
domain::RuntimeState state;
RuntimeState state;
require(state.policy().allowsProjectEditing, "editing mode must allow project editing");
require(state.enterOfflineRunning().succeeded, "editing may enter offline running");
require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers");
require(state.policy().runsLogicExecutor, "offline mode must run logic executor");
require(state.enterOnlineRunning(true).error
== domain::ModeTransitionError::MustReturnToEditing,
== ModeTransitionError::MustReturnToEditing,
"offline mode must not directly enter online mode");

require(state.enterEditing().succeeded, "offline mode may return to editing");
require(state.enterOnlineRunning(false).error
== domain::ModeTransitionError::InitialPlcReadRequired,
== ModeTransitionError::InitialPlcReadRequired,
"online mode must require an initial PLC read");
require(state.enterOnlineRunning(true).succeeded,
"editing may enter online mode after initial PLC read");


+ 137
- 0
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 <exception>
#include <iostream>
#include <stdexcept>
#include <string>

namespace {

class TestProjectStorage final : public ProjectStorage
{
public:
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
}

ProjectLoadResult load(const std::string &) override
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
}
};

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}

void testControlEditing()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService service(project_service);
const HmiEditorResult page_result = service.ensureDefaultPage();
require(page_result.succeeded, "default HMI page creation must succeed");

const std::string page_id = page_result.id;
const HmiEditorResult button = service.addControl(page_id, HmiControlType::Button);
const HmiEditorResult indicator = service.addControl(page_id, HmiControlType::Indicator);
const HmiEditorResult display = service.addControl(
page_id, HmiControlType::NumericDisplay);
const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput);
require(button.succeeded && indicator.succeeded && display.succeeded && input.succeeded,
"four basic HMI controls must be added");

const HmiPage *page = service.findPage(page_id);
require(page != nullptr && page->controls.size() == 4,
"all added controls must be kept in the page model");
require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded,
"a valid control move must succeed");
require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded,
"a move outside the page must be rejected");

HmiControl updated = *service.findControl(page_id, button.id);
updated.binding = RegisterAddress{RegisterArea::D, 0};
require(!service.updateControl(page_id, button.id, updated).succeeded,
"buttons must reject D address bindings during editing");
updated.binding = RegisterAddress{RegisterArea::M, 7};
updated.text = "启动";
require(service.updateControl(page_id, button.id, updated).succeeded,
"a button M binding and edited text must be accepted");

HmiControl duplicate = *service.findControl(page_id, indicator.id);
duplicate.id = button.id;
require(service.updateControl(page_id, indicator.id, duplicate).error
== HmiEditorError::DuplicateId,
"duplicate control ids must be rejected");

require(service.removeControl(page_id, indicator.id).succeeded,
"deleting a selected control must succeed");
require(service.findControl(page_id, indicator.id) == nullptr,
"deleted controls must not remain in the model");
}

void testRuntimeUsesRegisterRepository()
{
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);

HmiControl button;
button.id = "start";
button.type = HmiControlType::Button;
button.binding = RegisterAddress{RegisterArea::M, 12};
require(runtime_service.toggleButton(button).succeeded,
"button runtime writes must be accepted by the repository");
require(repository.readBit(*button.binding).value,
"button runtime writes must reach the M repository value");

HmiControl indicator;
indicator.id = "running";
indicator.type = HmiControlType::Indicator;
indicator.binding = RegisterAddress{RegisterArea::M, 12};
const HmiRuntimeReadResult indicator_value = runtime_service.readControl(indicator);
require(indicator_value.succeeded && indicator_value.bit_value,
"indicators must read M values through the repository");

HmiControl numeric_input;
numeric_input.id = "target";
numeric_input.type = HmiControlType::NumericInput;
numeric_input.binding = RegisterAddress{RegisterArea::D, 9};
require(runtime_service.writeNumericInput(numeric_input, -18).succeeded,
"numeric input runtime writes must be accepted by the repository");

HmiControl numeric_display;
numeric_display.id = "actual";
numeric_display.type = HmiControlType::NumericDisplay;
numeric_display.binding = RegisterAddress{RegisterArea::D, 9};
const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display);
require(numeric_value.succeeded && numeric_value.word_value == -18,
"numeric display must read D values through the repository");
}

} // namespace

int main()
{
try
{
testControlEditing();
testRuntimeUsesRegisterRepository();
}
catch (const std::exception &error)
{
std::cerr << "HMI editor service tests failed: " << error.what() << '\n';
return 1;
}

std::cout << "HMI editor service tests passed\n";
return 0;
}

+ 29
- 0
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

+ 152
- 0
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 <QAction>
#include <QApplication>
#include <QDockWidget>
#include <QLabel>
#include <QLineEdit>

#include <iostream>
#include <stdexcept>
#include <string>

namespace {

class TestProjectStorage final : public ProjectStorage
{
public:
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
}

ProjectLoadResult load(const std::string &) override
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
}
};

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}

template<typename ObjectType>
ObjectType *requiredChild(MainWindow &window, const char *name)
{
ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
require(child != nullptr, std::string("missing UI object ") + name);
return child;
}

void testModeActionsControlEditingAvailability()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
RuntimeModeService mode_service;
MainWindow window(
mode_service, project_service, editor_service, runtime_service);
window.resize(1000, 640);
window.show();
QApplication::processEvents();

QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
window, "hmiEditorWidget");

const qreal compact_scale = hmi_editor->transform().m11();
window.resize(1600, 900);
QApplication::processEvents();
require(hmi_editor->transform().m11() > compact_scale,
"HMI page must refit when the window becomes larger");

require(editing_action->isChecked(), "editing action must be selected initially");
require(project_dock->isEnabled(), "project dock must be enabled while editing");
require(properties_dock->isEnabled(), "properties dock must be enabled while editing");

add_button_action->trigger();
const std::string page_id = editor_service.firstPageId();
require(editor_service.findPage(page_id)->controls.size() == 1,
"adding a control must update the HMI page model");
require(selection->text() == QStringLiteral("button-1"),
"selecting an added control must update the property panel");
require(text_edit->text() == QStringLiteral("按钮"),
"property panel must show the control text");
delete_control_action->trigger();
require(editor_service.findPage(page_id)->controls.empty(),
"deleting a selected control must update the HMI page model");

offline_action->trigger();
require(mode_service.mode() == ApplicationMode::OfflineRunning,
"offline action must enter offline running");
require(!project_dock->isEnabled(),
"project dock must be disabled while running");
require(!properties_dock->isEnabled(),
"properties dock must be disabled while running");
require(!add_button_action->isEnabled(),
"HMI add controls must be disabled while running");

online_action->trigger();
require(mode_service.mode() == ApplicationMode::OfflineRunning,
"running modes must not switch directly through the UI");
require(offline_action->isChecked(),
"failed mode changes must restore the active action");

editing_action->trigger();
require(mode_service.mode() == ApplicationMode::Editing,
"editing action must return to editing");
require(project_dock->isEnabled(),
"project dock must be restored after returning to editing");
require(properties_dock->isEnabled(),
"properties dock must be restored after returning to editing");
require(add_button_action->isEnabled(),
"HMI add controls must be restored after returning to editing");

online_action->trigger();
require(mode_service.mode() == ApplicationMode::Editing,
"online action must require an initial PLC read");
require(editing_action->isChecked(),
"rejected online running must restore the editing action");
}

} // namespace

int main(int argc, char *argv[])
{
qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
QApplication application(argc, argv);

try
{
testModeActionsControlEditingAvailability();
}
catch (const std::exception &error)
{
std::cerr << "main window tests failed: " << error.what() << '\n';
return 1;
}

std::cout << "main window tests passed\n";
return 0;
}

+ 42
- 0
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

+ 70
- 39
app/tests/project_management_tests.cpp 파일 보기

@@ -10,10 +10,6 @@
#include <stdexcept>
#include <string>

namespace domain = integrated_platform::domain;
namespace infrastructure = integrated_platform::infrastructure;
namespace services = integrated_platform::services;

namespace {

void require(bool condition, const std::string &message)
@@ -24,41 +20,68 @@ void require(bool condition, const std::string &message)
}
}

domain::Project makeExampleProject()
Project makeExampleProject()
{
domain::HmiControl start_button;
HmiControl start_button;
start_button.id = "start-button";
start_button.type = domain::HmiControlType::Button;
start_button.type = HmiControlType::Button;
start_button.bounds = {10, 20, 120, 48};
start_button.text = "Start";
start_button.binding = domain::RegisterAddress{domain::RegisterArea::M, 0};
start_button.binding = RegisterAddress{RegisterArea::M, 0};
start_button.properties.emplace("color", "green");

domain::HmiPage page;
HmiControl running_indicator;
running_indicator.id = "running-indicator";
running_indicator.type = HmiControlType::Indicator;
running_indicator.bounds = {150, 20, 64, 64};
running_indicator.text = "Running";
running_indicator.binding = RegisterAddress{RegisterArea::M, 1};
running_indicator.properties.emplace("activeColor", "#24a148");

HmiControl temperature_display;
temperature_display.id = "temperature-display";
temperature_display.type = HmiControlType::NumericDisplay;
temperature_display.bounds = {10, 90, 120, 40};
temperature_display.text = "Temperature";
temperature_display.binding = RegisterAddress{RegisterArea::D, 2};
temperature_display.properties.emplace("format", "decimal");

HmiControl target_input;
target_input.id = "target-input";
target_input.type = HmiControlType::NumericInput;
target_input.bounds = {150, 90, 120, 40};
target_input.text = "Target";
target_input.binding = RegisterAddress{RegisterArea::D, 3};
target_input.properties.emplace("minimum", "-100");

HmiPage page;
page.id = "main-page";
page.name = "Main";
page.controls.push_back(start_button);
page.controls.push_back(running_indicator);
page.controls.push_back(temperature_display);
page.controls.push_back(target_input);

domain::LogicNode contact;
LogicNode contact;
contact.id = "start-contact";
contact.config = domain::ContactNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 0},
domain::ContactMode::NormallyOpen};
contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};

domain::LogicNode compare;
LogicNode compare;
compare.id = "temperature-check";
compare.config = domain::CompareNodeConfig{
domain::RegisterAddress{domain::RegisterArea::D, 2},
domain::ComparisonOperator::GreaterThanOrEqual,
compare.config = CompareNodeConfig{
RegisterAddress{RegisterArea::D, 2},
ComparisonOperator::GreaterThanOrEqual,
static_cast<std::int16_t>(100)};

domain::LogicNode coil;
LogicNode coil;
coil.id = "run-coil";
coil.config = domain::CoilNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 1},
domain::CoilMode::Set};
coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1},
CoilMode::Set};

domain::ControlLogic logic;
ControlLogic logic;
logic.id = "start-logic";
logic.name = "Start logic";
logic.enabled = false;
@@ -66,7 +89,7 @@ domain::Project makeExampleProject()
logic.connections.push_back({contact.id, compare.id});
logic.connections.push_back({compare.id, coil.id});

domain::Project project;
Project project;
project.metadata = {"example-project", "Example project", "1.0"};
project.hmiPages.push_back(page);
project.controlLogics.push_back(logic);
@@ -92,8 +115,8 @@ void testEmptyProjectRoundTrip()
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
JsonProjectStorage storage;
ProjectService service(storage);
require(service.createNewProject("Empty project").succeeded,
"empty project creation must succeed");

@@ -116,8 +139,8 @@ void testExampleProjectRoundTrip()
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
JsonProjectStorage storage;
ProjectService service(storage);
service.editProject() = makeExampleProject();

const QString first_path = directory.filePath("example.json");
@@ -127,14 +150,22 @@ void testExampleProjectRoundTrip()
require(service.load(first_path.toStdString()).succeeded,
"example project load must succeed");

const domain::Project &project = service.project();
const Project &project = service.project();
require(project.metadata.id == "example-project", "project id must survive round trip");
require(project.hmiPages.size() == 1, "HMI page count must survive round trip");
require(project.hmiPages.front().controls.size() == 4,
"all basic HMI controls must survive round trip");
require(project.hmiPages.front().controls.front().binding->area()
== domain::RegisterArea::M,
== RegisterArea::M,
"HMI M binding must survive round trip");
require(project.hmiPages.front().controls.front().properties.at("color") == "green",
"HMI properties must survive round trip");
require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator,
"indicator control type must survive round trip");
require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D,
"numeric display D binding must survive round trip");
require(project.hmiPages.front().controls.at(3).bounds.x == 150,
"numeric input bounds must survive round trip");
require(project.controlLogics.size() == 1,
"control logic count must survive round trip");
require(!project.controlLogics.front().enabled,
@@ -142,7 +173,7 @@ void testExampleProjectRoundTrip()
require(project.controlLogics.front().nodes.size() == 3,
"logic node count must survive round trip");

const auto &compare = std::get<domain::CompareNodeConfig>(
const auto &compare = std::get<CompareNodeConfig>(
project.controlLogics.front().nodes.at(1).config);
require(compare.address.index() == 2 && compare.value == 100,
"comparison configuration must survive round trip");
@@ -158,8 +189,8 @@ void testInvalidFiles()
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
JsonProjectStorage storage;
ProjectService service(storage);
service.editProject().metadata.name = "Current project";
const QString invalid_json = directory.filePath("invalid-json.json");
const QString missing_field = directory.filePath("missing-field.json");
@@ -168,7 +199,7 @@ void testInvalidFiles()
writeText(invalid_json, "{");
auto result = service.load(invalid_json.toStdString());
require(!result.succeeded
&& result.storageError == domain::ProjectStorageError::InvalidJson,
&& result.storageError == ProjectStorageError::InvalidJson,
"invalid JSON must be rejected");
require(service.project().metadata.name == "Current project",
"invalid load must keep current project");
@@ -176,13 +207,13 @@ void testInvalidFiles()
writeText(missing_field, R"({"formatVersion":"1.0"})");
result = service.load(missing_field.toStdString());
require(!result.succeeded
&& result.storageError == domain::ProjectStorageError::MissingField,
&& result.storageError == ProjectStorageError::MissingField,
"missing fields must be rejected");

writeText(unsupported_version, R"({"formatVersion":"2.0"})");
result = service.load(unsupported_version.toStdString());
require(!result.succeeded
&& result.storageError == domain::ProjectStorageError::UnsupportedVersion,
&& result.storageError == ProjectStorageError::UnsupportedVersion,
"unsupported versions must be rejected");
}

@@ -191,12 +222,12 @@ void testServiceStateAndSaveErrors()
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
require(service.save().error == services::ProjectServiceError::FilePathRequired,
JsonProjectStorage storage;
ProjectService service(storage);
require(service.save().error == ProjectServiceError::FilePathRequired,
"save without a current path must be rejected");
require(service.createNewProject(" ").error
== services::ProjectServiceError::InvalidProjectName,
== ProjectServiceError::InvalidProjectName,
"blank project names must be rejected");

const QString path = directory.filePath("state.json");


+ 67
- 0
app/tests/runtime_mode_service_tests.cpp 파일 보기

@@ -0,0 +1,67 @@
#include "services/runtime_mode_service.h"

#include <iostream>
#include <stdexcept>
#include <string>

namespace {

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}

void testModeTransitions()
{
RuntimeModeService service;

require(service.mode() == ApplicationMode::Editing,
"service must start in editing mode");
require(service.policy().allowsProjectEditing,
"editing mode must allow project editing");

require(service.enterOfflineRunning().succeeded,
"editing mode must enter offline running");
require(service.policy().usesVirtualRegisters,
"offline running must use virtual registers");
require(service.enterOnlineRunning().error
== ModeTransitionError::MustReturnToEditing,
"running modes must not switch directly");

require(service.enterEditing().succeeded,
"offline running must return to editing");
require(service.enterOnlineRunning().error
== ModeTransitionError::InitialPlcReadRequired,
"online running must require an initial PLC read");

service.setInitialPlcReadCompleted(true);
require(service.initialPlcReadCompleted(),
"service must retain the initial PLC read state");
require(service.enterOnlineRunning().succeeded,
"online running must start after an initial PLC read");
require(service.policy().usesPlcRegisters,
"online running must use PLC registers");
require(!service.policy().runsLogicExecutor,
"online running must keep the software executor stopped");
}

} // namespace

int main()
{
try
{
testModeTransitions();
}
catch (const std::exception &error)
{
std::cerr << "runtime mode service tests failed: " << error.what() << '\n';
return 1;
}

std::cout << "runtime mode service tests passed\n";
return 0;
}

+ 18
- 0
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

+ 37
- 3
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: 为控制逻辑建立独立图形编辑器和服务层编辑用例

+ 11
- 1
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` 持续
添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。


불러오는 중...
취소
저장