Преглед изворни кода

feat: 完善 HMI 编辑器交互与运行模式控制

main
suyu пре 1 месец
родитељ
комит
074c32b3bb
23 измењених фајлова са 259 додато и 27 уклоњено
  1. +3
    -1
      .gitignore
  2. +5
    -5
      app/src/domain/hmi_model.h
  3. +2
    -6
      app/src/domain/project_model.cpp
  4. +2
    -0
      app/src/domain/register_address.cpp
  5. +2
    -0
      app/src/domain/runtime_state.cpp
  6. +24
    -3
      app/src/domain/runtime_state.h
  7. +2
    -0
      app/src/main.cpp
  8. +14
    -1
      app/src/services/hmi_editor_service.cpp
  9. +29
    -0
      app/src/services/hmi_editor_service.h
  10. +4
    -0
      app/src/services/hmi_runtime_service.cpp
  11. +5
    -5
      app/src/services/hmi_runtime_service.h
  12. +7
    -1
      app/src/services/project_service.cpp
  13. +2
    -0
      app/src/services/runtime_mode_service.cpp
  14. +30
    -1
      app/src/services/runtime_mode_service.h
  15. +47
    -0
      app/src/ui/hmi_editor_widget.cpp
  16. +11
    -0
      app/src/ui/hmi_editor_widget.h
  17. +32
    -4
      app/src/ui/main_window.cpp
  18. +14
    -0
      app/src/ui/main_window.h
  19. +7
    -0
      app/tests/domain_tests.cpp
  20. +4
    -0
      app/tests/hmi_editor_service_tests.cpp
  21. +4
    -0
      app/tests/main_window_tests.cpp
  22. +7
    -0
      app/tests/project_management_tests.cpp
  23. +2
    -0
      app/tests/runtime_mode_service_tests.cpp

+ 3
- 1
.gitignore Прегледај датотеку

@@ -58,4 +58,6 @@ Desktop.ini
/scripts
/docs/images
/docs/类图Mermaid代码.md
/other
/other
设计方案书.doc
/学习

+ 5
- 5
app/src/domain/hmi_model.h Прегледај датотеку

@@ -37,11 +37,11 @@ struct HmiRect
*/
enum class HmiControlType
{
Button,
Indicator,
NumericDisplay,
NumericInput,
Label
Button, // 按钮
Indicator, // 指示灯
NumericDisplay, // 数值显示
NumericInput, // 数值输入
Label // 标签
};

/**


+ 2
- 6
app/src/domain/project_model.cpp Прегледај датотеку

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

/**
* @brief 检查对象集合中是否存在重复 id
* @tparam TItem 包含 id 成员的对象类型
* @param items 需要检查的对象集合
* @return 存在重复 id 时返回 true 否则返回 false
*/
// 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验
template <typename TItem>
bool containsDuplicateId(const std::vector<TItem> &items)
{
@@ -59,6 +54,7 @@ bool Project::validate(std::string *error) const
}
for (const HmiPage &page : hmiPages)
{
// 工程聚合校验会向下委托页面和控件的完整规则
if (!page.validate(error))
{
return false;


+ 2
- 0
app/src/domain/register_address.cpp Прегледај датотеку

@@ -4,6 +4,7 @@ namespace {

bool isSupportedArea(RegisterArea area)
{
// 地址值对象只承认项目定义的 M 区和 D 区
return area == RegisterArea::M || area == RegisterArea::D;
}

@@ -43,6 +44,7 @@ std::string RegisterAddress::toString() const
areaName = "D";
break;
default:
// 未知枚举值不能伪装成有效 PLC 地址
return "InvalidRegisterAddress";
}



+ 2
- 0
app/src/domain/runtime_state.cpp Прегледај датотеку

@@ -12,6 +12,7 @@ ModePolicy RuntimeState::policy() const

ModeTransitionResult RuntimeState::enterEditing()
{
// 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点
if (mode_ == ApplicationMode::Editing)
{
return {false, ModeTransitionError::AlreadyInRequestedMode};
@@ -46,6 +47,7 @@ ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_comp
}
if (!initial_plc_read_completed)
{
// 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面
return {false, ModeTransitionError::InitialPlcReadRequired};
}
mode_ = ApplicationMode::OnlineRunning;


+ 24
- 3
app/src/domain/runtime_state.h Прегледај датотеку

@@ -30,15 +30,36 @@ constexpr ModePolicy policyForMode(ApplicationMode mode)
{
case ApplicationMode::Editing:
{
return {true, false, false, false, false};
// 编辑态只允许修改工程,不连接寄存器,也不执行软件逻辑
return {
true, // allowsProjectEditing 允许编辑工程
false, // usesVirtualRegisters 不使用虚拟寄存器
false, // usesPlcRegisters 不使用 PLC 寄存器
false, // runsLogicExecutor 不运行软件逻辑执行器
false // requiresInitialPlcRead 不要求 PLC 初次读取
};
}
case ApplicationMode::OfflineRunning:
{
return {false, true, false, true, false};
// 离线运行态禁止改工程,使用虚拟 M/D 并运行软件逻辑
return {
false, // allowsProjectEditing 禁止编辑工程
true, // usesVirtualRegisters 使用虚拟寄存器
false, // usesPlcRegisters 不使用 PLC 寄存器
true, // runsLogicExecutor 运行软件逻辑执行器
false // requiresInitialPlcRead 不要求 PLC 初次读取
};
}
case ApplicationMode::OnlineRunning:
{
return {false, false, true, false, true};
// 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC
return {
false, // allowsProjectEditing 禁止编辑工程
false, // usesVirtualRegisters 不使用虚拟寄存器
true, // usesPlcRegisters 使用 PLC 寄存器
false, // runsLogicExecutor 不运行软件逻辑执行器
true // requiresInitialPlcRead 必须先完成 PLC 初次读取
};
}
default:
{


+ 2
- 0
app/src/main.cpp Прегледај датотеку

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

// 组合根负责创建具体实现并将抽象依赖注入服务和 UI
JsonProjectStorage project_storage;
ProjectService project_service(project_storage);
HmiEditorService hmi_editor_service(project_service);
// 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现
VirtualRegisterRepository virtual_register_repository;
HmiRuntimeService hmi_runtime_service(virtual_register_repository);
RuntimeModeService runtime_mode_service;


+ 14
- 1
app/src/services/hmi_editor_service.cpp Прегледај датотеку

@@ -83,6 +83,7 @@ HmiEditorService::HmiEditorService(ProjectService &project_service)
{
}

// 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针
const HmiPage *HmiEditorService::findPage(const std::string &page_id) const
{
const Project &project = project_service_.project();
@@ -96,6 +97,7 @@ const HmiPage *HmiEditorService::findPage(const std::string &page_id) const
return page == project.hmiPages.cend() ? nullptr : &*page;
}

// 先找到页面 → 在页面内查找指定控件;页面不存在 / 控件不存在都返回 nullptr
const HmiControl *HmiEditorService::findControl(
const std::string &page_id, const std::string &control_id) const
{
@@ -114,12 +116,14 @@ const HmiControl *HmiEditorService::findControl(
return control == page->controls.cend() ? nullptr : &*control;
}

// 获取第一个页面 ID;没有页面返回空字符串
std::string HmiEditorService::firstPageId() const
{
const Project &project = project_service_.project();
return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id;
}

// 保证工程至少存在一个 HMI 页面
HmiEditorResult HmiEditorService::ensureDefaultPage()
{
if (!project_service_.project().hmiPages.empty())
@@ -127,6 +131,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
return {true, HmiEditorError::None, {}, firstPageId()};
}

// 仅在首个控件操作前创建默认页面,空工程仍可正常保存
HmiPage page;
page.id = "page-1";
page.name = "主操作页面";
@@ -145,6 +150,7 @@ HmiEditorResult HmiEditorService::addControl(
return failure(HmiEditorError::PageNotFound, "HMI page was not found");
}

// 新控件初始不绑定寄存器,避免自动分配地址造成误写风险
HmiControl control = makeControl(*page, type);
Project &project = project_service_.editProject();
auto target_page = std::find_if(
@@ -193,6 +199,7 @@ HmiEditorResult HmiEditorService::removeControl(
return {true, HmiEditorError::None, {}, control_id};
}

// 移动 / 缩放控件
HmiEditorResult HmiEditorService::moveControl(
const std::string &page_id,
const std::string &control_id,
@@ -209,8 +216,10 @@ HmiEditorResult HmiEditorService::moveControl(
return failure(HmiEditorError::ControlNotFound, "HMI control was not found");
}

// 先校验候选位置,失败时不修改工程模型
HmiControl candidate = *control;
candidate.bounds = bounds;
// 更新前保留原控件,只有全部编辑规则通过才覆盖原值
std::string error;
if (!validateEditableControl(*page, candidate, &error))
{
@@ -228,6 +237,7 @@ HmiEditorResult HmiEditorService::moveControl(
return {true, HmiEditorError::None, {}, control_id};
}

// 更新控件全部属性
HmiEditorResult HmiEditorService::updateControl(
const std::string &page_id,
const std::string &control_id,
@@ -327,10 +337,11 @@ bool HmiEditorService::hasDuplicateControlId(
page.controls.cbegin(), page.controls.cend(),
[&excluded_id, &candidate_id](const HmiControl &item)
{
return item.id != excluded_id && item.id == candidate_id;
return item.id != excluded_id && item.id == candidate_id; // 如果这个控件不是正在编辑的原控件,并且它的 ID 等于准备使用的新 ID
});
}

// 根据控件类型生成默认样式、文字、大小、初始坐标
HmiControl HmiEditorService::makeControl(
const HmiPage &page, HmiControlType type)
{
@@ -346,9 +357,11 @@ HmiControl HmiEditorService::makeControl(
return control;
}

// 生成页面内不重复控件 ID,前缀 + 自增数字
std::string HmiEditorService::makeUniqueId(
const HmiPage &page, const std::string &prefix)
{
// 同类控件从 1 递增命名,保证页面内标识稳定且唯一
int suffix = 1;
while (true)
{


+ 29
- 0
app/src/services/hmi_editor_service.h Прегледај датотеку

@@ -118,18 +118,47 @@ public:
const HmiControl &control);

private:
/**
* @brief 校验控件编辑后仍满足页面内的基础约束
* @param page 控件所属页面
* @param control 待校验控件
* @param error 校验失败时写入原因,可为 nullptr
* @return 控件标识、尺寸、位置和寄存器绑定区域均有效时返回 true
*/
static bool validateEditableControl(
const HmiPage &page,
const HmiControl &control,
std::string *error);
/**
* @brief 检查候选控件标识是否与页面内其他控件重复
* @param page 待检查页面
* @param excluded_id 更新场景中需要排除的原控件标识
* @param candidate_id 待使用的控件标识
* @return 存在同标识的其他控件时返回 true
*/
static bool hasDuplicateControlId(
const HmiPage &page,
const std::string &excluded_id,
const std::string &candidate_id);
/**
* @brief 按控件类型创建带默认属性的新控件
* @param page 新控件所属页面,用于确定初始位置和唯一标识
* @param type 新控件类型
* @return 未绑定寄存器的默认控件配置
*/
static HmiControl makeControl(
const HmiPage &page, HmiControlType type);
/**
* @brief 在页面内生成带指定前缀的唯一控件标识
* @param page 待检查页面
* @param prefix 控件类型对应的标识前缀
* @return 从 1 开始递增且不与现有控件重复的标识
*/
static std::string makeUniqueId(
const HmiPage &page, const std::string &prefix);

/**
* @brief 非拥有的工程服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
};

+ 4
- 0
app/src/services/hmi_runtime_service.cpp Прегледај датотеку

@@ -32,6 +32,7 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository)

HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const
{
// 标签等静态控件不参与寄存器读写
if (!isBitControl(control.type) && !isWordControl(control.type))
{
return readFailure(HmiRuntimeError::UnsupportedControl);
@@ -46,6 +47,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
}
if (isBitControl(control.type))
{
// 按控件类型选择仓库的 M 位读取契约
const BitReadResult result = repository_.readBit(*control.binding);
if (!result.succeeded)
{
@@ -76,6 +78,7 @@ HmiRuntimeWriteResult HmiRuntimeService::toggleButton(const HmiControl &control)
{
return writeFailure(current.error);
}
// 先读后取反实现按钮点击切换 M 位语义
const RegisterWriteResult result = repository_.writeBit(
*control.binding, !current.bit_value);
return result.succeeded
@@ -106,6 +109,7 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput(

HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error)
{
// 领域仓库错误被收敛为 HMI 可理解的运行错误
switch (error)
{
case RegisterError::InvalidAddress:


+ 5
- 5
app/src/services/hmi_runtime_service.h Прегледај датотеку

@@ -17,11 +17,11 @@
*/
enum class HmiRuntimeError
{
None,
UnsupportedControl,
MissingBinding,
InvalidBinding,
RepositoryFailure
None, // 无错误
UnsupportedControl, // 控件类型不支持寄存器读写
MissingBinding, // 控件未配置寄存器绑定
InvalidBinding, // 控件绑定地址无效或地址区域不匹配
RepositoryFailure // 寄存器仓库读取或写入失败
};

/**


+ 7
- 1
app/src/services/project_service.cpp Прегледај датотеку

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

namespace {

// 使用高精度时间戳和随机值生成工程标识
// 生成唯一工程 ID:project-时间戳-随机数
std::string generateProjectId()
{
const auto timestamp = static_cast<unsigned long long>(
@@ -51,6 +51,7 @@ bool ProjectService::hasCurrentFile() const
return !current_file_path_.empty();
}

// 查询是否存在未保存修改
bool ProjectService::isModified() const
{
return modified_;
@@ -82,6 +83,7 @@ ProjectOperationResult ProjectService::save()
ProjectStorageError::None,
"project file path is required"};
}
// 复用另存为流程,确保两种保存方式拥有相同的校验和错误处理
return saveAs(current_file_path_);
}

@@ -149,15 +151,18 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

// 创建一个全新空白工程实例
Project ProjectService::makeNewProject(const std::string &name)
{
Project project;
project.metadata.id = generateProjectId();
project.metadata.name = name;
// 新工程固定使用当前存储格式版本
project.metadata.formatVersion = "1.0";
return project;
}

// 判断字符串是不是空白:空字符串 / 全是空格、制表符都算空白
bool ProjectService::isBlank(const std::string &value)
{
return value.empty()
@@ -170,6 +175,7 @@ bool ProjectService::isBlank(const std::string &value)
});
}

// 包装存储层失败结果
ProjectOperationResult ProjectService::storageFailure(
ProjectStorageError error, const std::string &message)
{


+ 2
- 0
app/src/services/runtime_mode_service.cpp Прегледај датотеку

@@ -30,11 +30,13 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()

ModeTransitionResult RuntimeModeService::enterOnlineRunning()
{
// 首次 PLC 读取状态由服务维护,再交给领域状态机统一裁决
return state_.enterOnlineRunning(initial_plc_read_completed_);
}

void RuntimeModeService::setInitialPlcReadCompleted(bool completed)
{
// 通信服务完成有效读回后才允许把此标志设为 true
initial_plc_read_completed_ = completed;
}



+ 30
- 1
app/src/services/runtime_mode_service.h Прегледај датотеку

@@ -14,15 +14,44 @@
class RuntimeModeService
{
public:
/**
* @brief 获取当前应用运行模式
* @return 当前的编辑、离线运行或真机运行模式
*/
ApplicationMode mode() const;
/**
* @brief 获取当前模式下允许的工程编辑和寄存器使用策略
* @return 与当前运行模式对应的能力策略
*/
ModePolicy policy() const;

/**
* @brief 退出离线或真机运行态并返回编辑态
* @return 已处于编辑态或状态机拒绝切换时返回失败结果
*/
ModeTransitionResult enterEditing();
/**
* @brief 从编辑态进入离线运行态
* @return 非编辑态进入时返回必须先回到编辑态的失败结果
*/
ModeTransitionResult enterOfflineRunning();
/**
* @brief 从编辑态进入真机运行态
* @return 未完成 PLC 初次读取或非编辑态进入时返回失败结果
*/
ModeTransitionResult enterOnlineRunning();

// 由后续 PLC 通信服务在首次读取成功或缓存失效时更新
/**
* @brief 更新 PLC 是否已完成首次有效读取
* @param completed 为 true 时允许在编辑态进入真机运行模式
*
* 由 PLC 通信服务在首次读取成功或缓存失效时调用
*/
void setInitialPlcReadCompleted(bool completed);
/**
* @brief 查询 PLC 是否已完成首次有效读取
* @return 已建立可用于进入真机运行态的 PLC 缓存时返回 true
*/
bool initialPlcReadCompleted() const;

private:


+ 47
- 0
app/src/ui/hmi_editor_widget.cpp Прегледај датотеку

@@ -20,9 +20,11 @@

namespace {

// 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元
class HmiGraphicsItem final : public QGraphicsItem
{
public:
// 保存控件快照、页面边界和回调,使图元不直接依赖服务层
HmiGraphicsItem(
const HmiControl &control,
int page_width,
@@ -35,11 +37,17 @@ public:
moved_(std::move(moved)),
activated_(std::move(activated))
{
// 领域坐标直接作为图元在场景中的初始位置
setPos(control_.bounds.x, control_.bounds.y);
// 所有控件均可选中,便于主窗口显示对应属性
setFlag(ItemIsSelectable, true);
// 开启可选中
setFlag(ItemSendsGeometryChanges, true);
// 画布只处理左键交互,保留其他按键给视图默认行为
setAcceptedMouseButtons(Qt::LeftButton);
}

// 返回图元自身坐标系中的矩形范围,用于绘制和命中测试
QRectF boundingRect() const override
{
return {0,
@@ -53,6 +61,7 @@ public:
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
// 留出一个像素边距,避免描边被图元边界裁剪
const QRectF rect = boundingRect().adjusted(1, 1, -1, -1);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1));
@@ -61,6 +70,7 @@ public:
{
case HmiControlType::Button:
{
// 按钮显示文字,运行态点击行为由鼠标事件处理
painter->setBrush(QColor(QStringLiteral("#dcece3")));
painter->drawRoundedRect(rect, 4, 4);
painter->setPen(QColor(QStringLiteral("#205c3b")));
@@ -69,6 +79,7 @@ public:
}
case HmiControlType::Indicator:
{
// 指示灯颜色由最近一次读取到的 M 位值决定
const qreal diameter = std::min(rect.width(), rect.height() - 18.0);
const QRectF lamp(
rect.center().x() - diameter / 2.0,
@@ -88,6 +99,7 @@ public:
}
case HmiControlType::NumericDisplay:
{
// 数值显示为只读样式,文本由运行值刷新
painter->setBrush(QColor(QStringLiteral("#edf2f6")));
painter->drawRect(rect);
painter->setPen(QColor(QStringLiteral("#24313b")));
@@ -98,6 +110,7 @@ public:
}
case HmiControlType::NumericInput:
{
// 数值输入以白色编辑框样式呈现,双击后才请求写入
painter->setBrush(QColor(QStringLiteral("#ffffff")));
painter->drawRoundedRect(rect, 3, 3);
painter->setPen(QColor(QStringLiteral("#24313b")));
@@ -109,6 +122,7 @@ public:
case HmiControlType::Label:
default:
{
// 标签只显示固定文本,不绑定寄存器运行值
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->drawText(rect, Qt::AlignCenter,
QString::fromUtf8(control_.text.data(),
@@ -119,22 +133,26 @@ public:

if ((option->state & QStyle::State_Selected) != 0)
{
// 选中框独立于控件类型,提示当前可编辑对象
painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2));
painter->drawRect(boundingRect().adjusted(0, 0, -1, -1));
}
}

// 向场景和外部控件返回该图元对应的领域控件标识
const std::string &controlId() const
{
return control_.id;
}

// 编辑态允许拖动,运行态保留点击或双击交互
void setInteractionEnabled(bool editable)
{
setFlag(ItemIsMovable, editable);
}

// 缓存运行服务读出的值并触发 Qt 重绘
void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available)
{
bit_value_ = bit_value;
@@ -144,10 +162,13 @@ public:
}

protected:
// 拖拽过程中将新位置限制在页面可见边界内
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override
{
// 只有开启 ItemIsMovable 拖拽时,才做坐标钳位
if (change == ItemPositionChange && flags().testFlag(ItemIsMovable))
{
// 在图元层预先截断拖拽坐标,避免控件视觉上越出页面
QPointF position = value.toPointF();
const qreal maximum_x = std::max(
0.0, static_cast<double>(page_width_ - control_.bounds.width));
@@ -160,24 +181,30 @@ protected:
return QGraphicsItem::itemChange(change, value);
}

// 运行态点击按钮时通知外层执行 M 位切换
void mousePressEvent(QGraphicsSceneMouseEvent *event) override
{
// 条件:!ItemIsMovable 【也就是运行模式】 + 是按钮 + 有回调
if (!flags().testFlag(ItemIsMovable)
&& control_.type == HmiControlType::Button && activated_)
{
// 运行态单击按钮才触发 M 位切换,编辑态只用于选择和拖动
setSelected(true);
activated_(control_.id);
event->accept();
return;
}
// 编辑模式:不进if分支,执行基类事件——只做选中、拖拽
QGraphicsItem::mousePressEvent(event);
}

// 运行态双击数值输入时通知外层弹出数值编辑对话框
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override
{
if (!flags().testFlag(ItemIsMovable)
&& control_.type == HmiControlType::NumericInput && activated_)
{
// 数值输入使用双击,避免普通选择操作意外写入 D 字
setSelected(true);
activated_(control_.id);
event->accept();
@@ -186,9 +213,11 @@ protected:
QGraphicsItem::mouseDoubleClickEvent(event);
}

// 拖拽结束后才提交最终坐标,避免移动过程频繁修改领域模型
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override
{
QGraphicsItem::mouseReleaseEvent(event);
// 只有ItemIsMovable打开(编辑态),松开鼠标才提交位置给业务层
if (flags().testFlag(ItemIsMovable) && moved_)
{
moved_(control_.id, pos());
@@ -196,6 +225,7 @@ protected:
}

private:
// 根据控件类型和运行数据组合当前应绘制的文字
QString textWithValue() const
{
const QString text = QString::fromUtf8(
@@ -212,16 +242,25 @@ private:
return text;
}

// 图元创建时的领域控件快照,提供类型、尺寸、文本和标识
HmiControl control_;
// 当前页面宽度,用于限制图元横向拖拽范围
int page_width_ = 0;
// 当前页面高度,用于限制图元纵向拖拽范围
int page_height_ = 0;
// 拖拽完成后回调 HmiEditorWidget 提交控件新位置
std::function<void(const std::string &, const QPointF &)> moved_;
// 运行模式点击按钮 / 双击输入框回调,通知外层做寄存器读写
std::function<void(const std::string &)> activated_;
// 指示灯读取到的 M 位值
bool bit_value_ = false;
// 数值控件读取到的 D 字值
std::int16_t word_value_ = 0;
// 标记当前缓存值是否来自一次成功的运行时读取
bool has_runtime_value_ = false;
};

// 将场景通用图元安全转换为本文件定义的 HMI 控件图元
HmiGraphicsItem *asHmiItem(QGraphicsItem *item)
{
return dynamic_cast<HmiGraphicsItem *>(item);
@@ -247,6 +286,7 @@ HmiEditorWidget::HmiEditorWidget(
this, &HmiEditorWidget::handleSelectionChanged);
}

// 切换显示页面
void HmiEditorWidget::setPageId(const std::string &page_id)
{
if (page_id_ == page_id)
@@ -280,8 +320,10 @@ void HmiEditorWidget::setRuntimeActive(bool active)
}
}

// 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形
void HmiEditorWidget::reloadPage()
{
// 画布始终从当前领域页面重建,避免保留已删除控件的图元
scene_->clear();
const HmiPage *page = editor_service_.findPage(page_id_);
if (page == nullptr)
@@ -318,6 +360,7 @@ void HmiEditorWidget::reloadPage()
refreshRuntimeValues();
}

// 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来
void HmiEditorWidget::selectControl(const std::string &control_id)
{
for (QGraphicsItem *item : scene_->items())
@@ -365,6 +408,7 @@ void HmiEditorWidget::refreshRuntimeValues()
{
continue;
}
// 运行值通过服务读取,图元不直接接触寄存器仓库
const HmiRuntimeReadResult value = runtime_service_.readControl(*control);
control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded);
}
@@ -389,6 +433,7 @@ void HmiEditorWidget::fitCurrentPage()

void HmiEditorWidget::updateItemInteractions()
{
// 遍历所有控件图元,统一设置flag
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
@@ -412,6 +457,7 @@ void HmiEditorWidget::handleControlMoved(
{
return;
}
// 鼠标坐标取整后再交给服务校验并写回模型
HmiRect bounds = control->bounds;
bounds.x = static_cast<int>(std::lround(position.x()));
bounds.y = static_cast<int>(std::lround(position.y()));
@@ -438,6 +484,7 @@ void HmiEditorWidget::handleControlActivated(const std::string &control_id)
return;
}
HmiRuntimeWriteResult result;
// 只有可写控件允许激活,其余控件只展示最新运行值
if (control->type == HmiControlType::Button)
{
result = runtime_service_.toggleButton(*control);


+ 11
- 0
app/src/ui/hmi_editor_widget.h Прегледај датотеку

@@ -92,16 +92,27 @@ protected:
void resizeEvent(QResizeEvent *event) override;

private:
// 按页面尺寸缩放视图,保证完整画布保持可见
void fitCurrentPage();
// 将当前编辑开关同步到所有控件图元
void updateItemInteractions();
// 将场景选择转换为控件标识并通知属性面板
void handleSelectionChanged();
// 将图元拖动后的坐标提交给 HMI 编辑服务
void handleControlMoved(const std::string &control_id, const QPointF &position);
// 在运行态处理按钮点击或数值输入激活
void handleControlActivated(const std::string &control_id);

// 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据
HmiEditorService &editor_service_;
// 提供运行态寄存器读写能力,画布不直接访问寄存器仓库
HmiRuntimeService &runtime_service_;
// 持有所有页面图元和页面边框
QGraphicsScene *scene_ = nullptr;
// 当前画布投影的页面标识
std::string page_id_;
// 控制是否允许拖动和编辑控件
bool editing_enabled_ = true;
// 控制是否刷新运行值并响应运行态控件操作
bool runtime_active_ = false;
};

+ 32
- 4
app/src/ui/main_window.cpp Прегледај датотеку

@@ -134,10 +134,10 @@ MainWindow::MainWindow(
hmi_runtime_service_(hmi_runtime_service)
{
ui_->setupUi(this);
configureAppearance();
configureActions();
configurePropertyEditor();
configureHmiEditor();
configureAppearance(); // 窗口样式、布局、状态栏标签初始化,纯视觉代码,无业务逻辑
configureActions(); // 创建所有菜单 Action、工具栏按钮,绑定点击槽函数
configurePropertyEditor(); // 动态构建属性表单:ID、文本、坐标宽高、寄存器区域 (M/D)、地址索引 + 应用按钮
configureHmiEditor(); // 创建画布控件HmiEditorWidget嵌入主窗口,绑定画布信号:选中控件、控件拖动修改、编辑器报错;同时启动运行刷新定时器
hmi_editor_service_.ensureDefaultPage();
refreshProjectUi();
updateModeUi(tr("系统已进入编辑态"));
@@ -290,12 +290,14 @@ void MainWindow::configureHmiEditor()
connect(hmi_editor_widget_, &HmiEditorWidget::editorError,
this,
[this](const QString &message) { statusBar()->showMessage(message, 5000); });
// 运行态定时投影仓库最新值,编辑态不触发寄存器读取
runtime_refresh_timer_ = new QTimer(this);
runtime_refresh_timer_->setInterval(150);
connect(runtime_refresh_timer_, &QTimer::timeout,
this,
[this]
{
// 离线 / 联机运行模式
if (runtime_mode_service_.mode() != ApplicationMode::Editing)
{
hmi_editor_widget_->refreshRuntimeValues();
@@ -352,6 +354,7 @@ void MainWindow::configurePropertyEditor()

void MainWindow::refreshProjectUi()
{
// 工程树只展示模型摘要,画布内容由 HmiEditorWidget 单独投影
const std::string current_page_id = hmi_editor_service_.firstPageId();
if (hmi_editor_widget_ != nullptr)
{
@@ -376,14 +379,23 @@ void MainWindow::refreshProjectUi()
ui_->projectTree->expandAll();
}

// 根据控件ID加载控件属性到右侧属性面板
void MainWindow::showControlProperties(const std::string &control_id)
{
// 更新窗口全局状态:记录当前选中控件ID
selected_control_id_ = control_id;

// 在第一页画面中,根据ID查找目标控件,获取只读模型指针
const HmiControl *control = hmi_editor_service_.findControl(
hmi_editor_service_.firstPageId(), control_id);
// 判断是否查询到有效控件
const bool has_control = control != nullptr;

// 更新属性面板顶部状态标签,显示选中控件ID或提示未选择
ui_->selectionValueLabel->setText(
has_control ? fromUtf8(control->id) : tr("未选择"));

// 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑
for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_),
static_cast<QWidget *>(control_text_edit_),
static_cast<QWidget *>(control_x_spin_box_),
@@ -396,21 +408,33 @@ void MainWindow::showControlProperties(const std::string &control_id)
{
widget->setEnabled(has_control);
}

// 未选中有效控件,无需填充属性,直接退出
if (!has_control)
{
return;
}

// 将控件基础信息填充至UI输入框
control_id_edit_->setText(fromUtf8(control->id));
control_text_edit_->setText(fromUtf8(control->text));
// 控件位置坐标
control_x_spin_box_->setValue(control->bounds.x);
control_y_spin_box_->setValue(control->bounds.y);
// 控件宽高尺寸
control_width_spin_box_->setValue(control->bounds.width);
control_height_spin_box_->setValue(control->bounds.height);

// 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区
const RegisterArea default_area = usesMAddress(control->type)
? RegisterArea::M : RegisterArea::D;
// 优先使用控件已绑定的寄存器区域;无绑定则使用类型对应的默认区域
const RegisterArea area = control->binding.has_value()
? control->binding->area() : default_area;

// 同步寄存器区域下拉框选中项(0=M,1=D,与combo初始化顺序严格对应)
binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1);
// 填充寄存器地址索引;无绑定时默认地址为0
binding_index_spin_box_->setValue(
control->binding.has_value() ? control->binding->index() : 0);
}
@@ -458,6 +482,7 @@ void MainWindow::applySelectedControlProperties()
{
return;
}
// 从现有控件复制不可见属性,避免属性面板覆盖扩展配置
HmiControl control = *old_control;
control.id = toUtf8(control_id_edit_->text());
control.text = toUtf8(control_text_edit_->text());
@@ -566,6 +591,7 @@ void MainWindow::showProjectResult(

void MainWindow::requestMode(ApplicationMode requested_mode)
{
// UI 仅转发模式意图,合法性由服务层和领域状态机决定
ModeTransitionResult result;
switch (requested_mode)
{
@@ -605,6 +631,7 @@ void MainWindow::updateModeUi(const QString &message)
const ApplicationMode mode = runtime_mode_service_.mode();
const ModePolicy policy = runtime_mode_service_.policy();
restoreCurrentModeAction();
// 将同一份模式策略同步到所有可编辑入口,避免只禁用部分操作
ui_->projectDock->setEnabled(policy.allowsProjectEditing);
ui_->propertiesDock->setEnabled(policy.allowsProjectEditing);
hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing);
@@ -646,6 +673,7 @@ void MainWindow::updateModeUi(const QString &message)
ui_->outputList->scrollToBottom();
}

// 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态
void MainWindow::restoreCurrentModeAction()
{
ui_->editingModeAction->setChecked(runtime_mode_service_.mode()


+ 14
- 0
app/src/ui/main_window.h Прегледај датотеку

@@ -65,19 +65,33 @@ public:
~MainWindow() override;

private:
// 配置菜单和工具栏动作
void configureActions();
// 配置主窗口外观和状态栏
void configureAppearance();
// 创建并连接 HMI 编辑画布
void configureHmiEditor();
// 创建并连接控件属性编辑表单
void configurePropertyEditor();
// 刷新工程树和当前 HMI 页面信息
void refreshProjectUi();
// 显示指定 HMI 控件的可编辑属性
void showControlProperties(const std::string &control_id);
// 向当前页面添加指定类型的 HMI 控件
void addHmiControl(HmiControlType type);
// 删除当前选中的 HMI 控件
void deleteSelectedControl();
// 将属性表单内容应用到当前选中控件
void applySelectedControlProperties();
// 创建新的工程并刷新编辑界面
void createNewProject();
// 保存当前工程到已关联的路径
void saveProject();
// 将当前工程保存到用户指定的路径
void saveProjectAs();
// 从用户指定的路径加载工程
void loadProject();
// 在状态栏和输出面板显示工程操作结果
void showProjectResult(const QString &action, const QString &message, bool succeeded);

/**


+ 7
- 0
app/tests/domain_tests.cpp Прегледај датотеку

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

void testRegisterAddressBoundaries()
{
// 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径
require(RegisterAddress{RegisterArea::M, 0}.isValid(),
"M0 must be valid");
require(RegisterAddress{RegisterArea::D, 4000}.isValid(),
@@ -37,6 +38,7 @@ void testRegisterAddressBoundaries()

void testRegisterRepositorySeparatesAreas()
{
// 验证离线仓库不会把 M 位和 D 字交叉解释
VirtualRegisterRepository repository;
const RegisterAddress m0{RegisterArea::M, 0};
const RegisterAddress d0{RegisterArea::D, 0};
@@ -54,6 +56,7 @@ void testRegisterRepositorySeparatesAreas()

Project makeValidProject()
{
// 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线
HmiControl start_button;
start_button.id = "start-button";
start_button.type = HmiControlType::Button;
@@ -92,6 +95,7 @@ Project makeValidProject()

void testLogicNodeConfigurationBoundaries()
{
// 触点只能绑定 M 区,数值比较只能绑定 D 区
LogicNode contact;
contact.id = "contact";
contact.config = ContactNodeConfig{
@@ -115,6 +119,7 @@ void testLogicNodeConfigurationBoundaries()

void testModelsValidateBindingsAndIdentifiers()
{
// 聚合验证必须拒绝错误绑定、重复标识和越界控件
Project project = makeValidProject();
require(project.validate(), "valid project model must pass validation");

@@ -141,6 +146,7 @@ void testModelsValidateBindingsAndIdentifiers()

void testRuntimeStateBoundaries()
{
// 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件
RuntimeState state;
require(state.policy().allowsProjectEditing, "editing mode must allow project editing");
require(state.enterOfflineRunning().succeeded, "editing may enter offline running");
@@ -167,6 +173,7 @@ int main()
{
try
{
// 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程
testRegisterAddressBoundaries();
testRegisterRepositorySeparatesAreas();
testLogicNodeConfigurationBoundaries();


+ 4
- 0
app/tests/hmi_editor_service_tests.cpp Прегледај датотеку

@@ -14,6 +14,7 @@ namespace {
class TestProjectStorage final : public ProjectStorage
{
public:
// 编辑服务测试只关注内存模型变更,不依赖真实文件系统
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
@@ -35,6 +36,7 @@ void require(bool condition, const std::string &message)

void testControlEditing()
{
// 覆盖控件创建、移动、绑定校验、重命名冲突和删除流程
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService service(project_service);
@@ -81,6 +83,7 @@ void testControlEditing()

void testRuntimeUsesRegisterRepository()
{
// 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);

@@ -123,6 +126,7 @@ int main()
{
try
{
// 编辑和运行场景分别验证服务层两条独立职责
testControlEditing();
testRuntimeUsesRegisterRepository();
}


+ 4
- 0
app/tests/main_window_tests.cpp Прегледај датотеку

@@ -22,6 +22,7 @@ namespace {
class TestProjectStorage final : public ProjectStorage
{
public:
// 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
@@ -44,6 +45,7 @@ void require(bool condition, const std::string &message)
template<typename ObjectType>
ObjectType *requiredChild(MainWindow &window, const char *name)
{
// 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
require(child != nullptr, std::string("missing UI object ") + name);
return child;
@@ -51,6 +53,7 @@ ObjectType *requiredChild(MainWindow &window, const char *name)

void testModeActionsControlEditingAvailability()
{
// 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService editor_service(project_service);
@@ -134,6 +137,7 @@ void testModeActionsControlEditingAvailability()

int main(int argc, char *argv[])
{
// 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
QApplication application(argc, argv);



+ 7
- 0
app/tests/project_management_tests.cpp Прегледај датотеку

@@ -22,6 +22,7 @@ void require(bool condition, const std::string &message)

Project makeExampleProject()
{
// 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本
HmiControl start_button;
start_button.id = "start-button";
start_button.type = HmiControlType::Button;
@@ -98,6 +99,7 @@ Project makeExampleProject()

void writeText(const QString &path, const QByteArray &content)
{
// 直接写入故障样本文件,以验证加载失败时的保护行为
QFile file(path);
require(file.open(QIODevice::WriteOnly), "test file must be writable");
require(file.write(content) == content.size(), "test file must be written completely");
@@ -112,6 +114,7 @@ QByteArray readBytes(const QString &path)

void testEmptyProjectRoundTrip()
{
// 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

@@ -136,6 +139,7 @@ void testEmptyProjectRoundTrip()

void testExampleProjectRoundTrip()
{
// 验证各层嵌套字段往返后保持不变且序列化结果稳定
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

@@ -186,6 +190,7 @@ void testExampleProjectRoundTrip()

void testInvalidFiles()
{
// 非法文件必须被拒绝,并且不得覆盖服务中当前工程
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

@@ -219,6 +224,7 @@ void testInvalidFiles()

void testServiceStateAndSaveErrors()
{
// 保存路径和修改标记只在成功持久化后更新
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

@@ -252,6 +258,7 @@ int main()
{
try
{
// 工程服务和 JSON 存储在同一测试进程中验证完整闭环
testEmptyProjectRoundTrip();
testExampleProjectRoundTrip();
testInvalidFiles();


+ 2
- 0
app/tests/runtime_mode_service_tests.cpp Прегледај датотеку

@@ -16,6 +16,7 @@ void require(bool condition, const std::string &message)

void testModeTransitions()
{
// 验证服务将 PLC 首读状态与领域模式切换规则正确组合
RuntimeModeService service;

require(service.mode() == ApplicationMode::Editing,
@@ -54,6 +55,7 @@ int main()
{
try
{
// 运行模式只有这一组状态机边界测试
testModeTransitions();
}
catch (const std::exception &error)


Loading…
Откажи
Сачувај