Bläddra i källkod

feat: 支持编辑器撤销重做与批量删除

main
suyu 1 månad sedan
förälder
incheckning
9c9573ca43
24 ändrade filer med 1212 tillägg och 51 borttagningar
  1. +1
    -0
      app/integrated_platform.pro
  2. +84
    -0
      app/src/services/editor_history.h
  3. +188
    -7
      app/src/services/hmi_editor_service.cpp
  4. +27
    -0
      app/src/services/hmi_editor_service.h
  5. +332
    -11
      app/src/services/logic_editor_service.cpp
  6. +33
    -1
      app/src/services/logic_editor_service.h
  7. +28
    -4
      app/src/ui/hmi_editor_widget.cpp
  8. +2
    -0
      app/src/ui/hmi_editor_widget.h
  9. +4
    -10
      app/src/ui/logic_editor_widget.cpp
  10. +2
    -1
      app/src/ui/logic_editor_widget.h
  11. +192
    -10
      app/src/ui/main_window.cpp
  12. +10
    -0
      app/src/ui/main_window.h
  13. +44
    -1
      app/src/ui/main_window.ui
  14. +11
    -2
      app/src/ui/project_workspace_controller.cpp
  15. +4
    -1
      app/src/ui/project_workspace_controller.h
  16. +10
    -3
      app/src/ui/property_panel_controller.cpp
  17. +25
    -0
      app/src/ui/toolbar_icon_factory.cpp
  18. +2
    -0
      app/src/ui/toolbar_icon_factory.h
  19. +55
    -0
      app/tests/hmi_editor_service_tests.cpp
  20. +1
    -0
      app/tests/hmi_editor_service_tests.pro
  21. +72
    -0
      app/tests/logic_editor_service_tests.cpp
  22. +1
    -0
      app/tests/logic_editor_service_tests.pro
  23. +83
    -0
      app/tests/main_window_tests.cpp
  24. +1
    -0
      app/tests/main_window_tests.pro

+ 1
- 0
app/integrated_platform.pro Visa fil

@@ -80,6 +80,7 @@ HEADERS += \
src/domain/runtime_state.h \
src/domain/project_storage.h \
src/services/project_service.h \
src/services/editor_history.h \
src/services/alarm_editor_service.h \
src/services/alarm_service.h \
src/services/hmi_editor_service.h \


+ 84
- 0
app/src/services/editor_history.h Visa fil

@@ -0,0 +1,84 @@
#pragma once

#include <cstddef>
#include <optional>
#include <utility>
#include <vector>

/**
* @brief 保存有限数量的编辑前快照并提供撤销、重做
*
* 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。
*/
template <typename State>
class EditorHistory final
{
public:
static constexpr std::size_t kMaximumEntries = 100U;

template <typename Equal>
void record(State before, const State &after, Equal equal)
{
if (equal(before, after))
{
return;
}
undo_states_.push_back(std::move(before));
trim(&undo_states_);
redo_states_.clear();
}

std::optional<State> undo(State current)
{
if (undo_states_.empty())
{
return std::nullopt;
}
redo_states_.push_back(std::move(current));
trim(&redo_states_);
State target = std::move(undo_states_.back());
undo_states_.pop_back();
return target;
}

std::optional<State> redo(State current)
{
if (redo_states_.empty())
{
return std::nullopt;
}
undo_states_.push_back(std::move(current));
trim(&undo_states_);
State target = std::move(redo_states_.back());
redo_states_.pop_back();
return target;
}

void clear()
{
undo_states_.clear();
redo_states_.clear();
}

bool canUndo() const
{
return !undo_states_.empty();
}

bool canRedo() const
{
return !redo_states_.empty();
}

private:
static void trim(std::vector<State> *states)
{
while (states->size() > kMaximumEntries)
{
states->erase(states->begin());
}
}

std::vector<State> undo_states_;
std::vector<State> redo_states_;
};

+ 188
- 7
app/src/services/hmi_editor_service.cpp Visa fil

@@ -6,6 +6,8 @@
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <map>
#include <set>
#include <utility>

namespace {
@@ -55,6 +57,86 @@ HmiEditorService::HmiEditorService(ProjectService &project_service)
{
}

HmiEditorService::HistoryState HmiEditorService::captureState() const
{
const Project &project = project_service_.project();
return {project.hmiPages, project.initialHmiPageId};
}

void HmiEditorService::recordHistory(HistoryState before)
{
const HistoryState after = captureState();
history_.record(std::move(before), after, &HmiEditorService::statesEqual);
}

bool HmiEditorService::statesEqual(
const HistoryState &left, const HistoryState &right)
{
if (left.initial_page_id != right.initial_page_id
|| left.pages.size() != right.pages.size())
{
return false;
}
for (std::size_t index = 0; index < left.pages.size(); ++index)
{
if (!pagesEqual(left.pages[index], right.pages[index]))
{
return false;
}
}
return true;
}

bool HmiEditorService::pagesEqual(
const HmiPage &left, const HmiPage &right)
{
if (left.id != right.id || left.name != right.name
|| left.width != right.width || left.height != right.height
|| left.controls.size() != right.controls.size())
{
return false;
}
for (std::size_t index = 0; index < left.controls.size(); ++index)
{
if (!controlsEqual(left.controls[index], right.controls[index]))
{
return false;
}
}
return true;
}

bool HmiEditorService::controlsEqual(
const HmiControl &left, const HmiControl &right)
{
const bool progress_equal = left.progressBar.has_value()
== right.progressBar.has_value()
&& (!left.progressBar.has_value()
|| (left.progressBar->minimumValue == right.progressBar->minimumValue
&& left.progressBar->maximumValue == right.progressBar->maximumValue
&& left.progressBar->showValue == right.progressBar->showValue));
const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value()
&& (!left.pageJump.has_value()
|| left.pageJump->targetPageId == right.pageJump->targetPageId);
return left.id == right.id
&& left.type == right.type
&& left.bounds.x == right.bounds.x
&& left.bounds.y == right.bounds.y
&& left.bounds.width == right.bounds.width
&& left.bounds.height == right.bounds.height
&& left.text == right.text
&& left.binding == right.binding
&& left.properties == right.properties
&& left.buttonOperation == right.buttonOperation
&& page_jump_equal
&& progress_equal;
}

HmiEditorResult HmiEditorService::historyFailure(const std::string &message)
{
return {false, HmiEditorError::InvalidOperation, message, {}};
}

// 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针
const HmiPage *HmiEditorService::findPage(const std::string &page_id) const
{
@@ -109,6 +191,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
}

// 仅在首个控件操作前创建默认页面,空工程仍可正常保存
HistoryState before = captureState();
HmiPage page;
page.id = "page-1";
page.name = "主操作页面";
@@ -116,6 +199,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
project.initialHmiPageId = project.hmiPages.back().id;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

@@ -137,12 +221,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
HmiPage page;
page.id = makeUniquePageId(current);
page.name = name;
HistoryState before = captureState();
Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
if (project.initialHmiPageId.empty())
{
project.initialHmiPageId = project.hmiPages.back().id;
}
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

@@ -171,12 +257,18 @@ HmiEditorResult HmiEditorService::renamePage(
{
return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一");
}
if (existing->name == name)
{
return {true, HmiEditorError::None, {}, page_id};
}

HistoryState before = captureState();
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->name = name;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -218,12 +310,14 @@ HmiEditorResult HmiEditorService::removePage(const std::string &page_id)
}
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
project.hmiPages.erase(
std::remove_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &candidate) { return candidate.id == page_id; }),
project.hmiPages.end());
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -249,10 +343,12 @@ HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offse
return failure(HmiEditorError::InvalidOperation, "HMI 页面已经位于目标边界");
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
std::iter_swap(
project.hmiPages.begin() + index,
project.hmiPages.begin() + target_index);
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -262,7 +358,13 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id)
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (project_service_.project().initialHmiPageId == page_id)
{
return {true, HmiEditorError::None, {}, page_id};
}
HistoryState before = captureState();
project_service_.editProject().initialHmiPageId = page_id;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -282,15 +384,17 @@ HmiEditorResult HmiEditorService::addControl(

// 新控件初始不绑定寄存器,避免自动分配地址造成误写风险
HmiControl control = makeControl(*page, *descriptor);
HistoryState before = captureState();
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;
{
return candidate.id == page_id;
});
target_page->controls.push_back(std::move(control));
recordHistory(std::move(before));
return {true,
HmiEditorError::None,
{},
@@ -299,16 +403,36 @@ HmiEditorResult HmiEditorService::addControl(

HmiEditorResult HmiEditorService::removeControl(
const std::string &page_id, const std::string &control_id)
{
return removeControls(page_id, {control_id});
}

HmiEditorResult HmiEditorService::removeControls(
const std::string &page_id,
const std::vector<std::string> &control_ids)
{
if (findPage(page_id) == nullptr)
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (findControl(page_id, control_id) == nullptr)
if (control_ids.empty())
{
return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件");
return failure(HmiEditorError::InvalidOperation, "请先选择要删除的 HMI 控件");
}
std::set<std::string> selected_ids;
for (const std::string &control_id : control_ids)
{
if (!selected_ids.insert(control_id).second)
{
return failure(HmiEditorError::InvalidOperation, "删除列表中存在重复控件");
}
if (findControl(page_id, control_id) == nullptr)
{
return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件");
}
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
auto page = std::find_if(
project.hmiPages.begin(),
@@ -321,12 +445,13 @@ HmiEditorResult HmiEditorService::removeControl(
std::remove_if(
page->controls.begin(),
page->controls.end(),
[&control_id](const HmiControl &candidate)
[&selected_ids](const HmiControl &candidate)
{
return candidate.id == control_id;
return selected_ids.find(candidate.id) != selected_ids.end();
}),
page->controls.end());
return {true, HmiEditorError::None, {}, control_id};
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, control_ids.front()};
}

// 移动 / 缩放控件
@@ -355,7 +480,15 @@ HmiEditorResult HmiEditorService::moveControl(
{
return failure(HmiEditorError::InvalidControl, error);
}
if (control->bounds.x == bounds.x
&& control->bounds.y == bounds.y
&& control->bounds.width == bounds.width
&& control->bounds.height == bounds.height)
{
return {true, HmiEditorError::None, {}, control_id};
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
@@ -364,6 +497,7 @@ HmiEditorResult HmiEditorService::moveControl(
target->controls.begin(), target->controls.end(),
[&control_id](const HmiControl &item) { return item.id == control_id; });
editable->bounds = bounds;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, control_id};
}

@@ -398,6 +532,11 @@ HmiEditorResult HmiEditorService::updateControl(
return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一");
}

if (controlsEqual(*existing, control))
{
return {true, HmiEditorError::None, {}, control.id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
@@ -406,9 +545,51 @@ HmiEditorResult HmiEditorService::updateControl(
target->controls.begin(), target->controls.end(),
[&control_id](const HmiControl &item) { return item.id == control_id; });
*editable = control;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, control.id};
}

bool HmiEditorService::canUndo() const
{
return history_.canUndo();
}

bool HmiEditorService::canRedo() const
{
return history_.canRedo();
}

HmiEditorResult HmiEditorService::undo()
{
const std::optional<HistoryState> target = history_.undo(captureState());
if (!target.has_value())
{
return historyFailure("没有可撤销的 HMI 编辑操作");
}
Project &project = project_service_.editProject();
project.hmiPages = target->pages;
project.initialHmiPageId = target->initial_page_id;
return {true, HmiEditorError::None, {}, {}};
}

HmiEditorResult HmiEditorService::redo()
{
const std::optional<HistoryState> target = history_.redo(captureState());
if (!target.has_value())
{
return historyFailure("没有可重做的 HMI 编辑操作");
}
Project &project = project_service_.editProject();
project.hmiPages = target->pages;
project.initialHmiPageId = target->initial_page_id;
return {true, HmiEditorError::None, {}, {}};
}

void HmiEditorService::clearHistory()
{
history_.clear();
}

bool HmiEditorService::validateEditableControl(
const HmiPage &page, const HmiControl &control, std::string *error) const
{


+ 27
- 0
app/src/services/hmi_editor_service.h Visa fil

@@ -8,8 +8,10 @@
#pragma once

#include "domain/hmi_model.h"
#include "editor_history.h"

#include <string>
#include <vector>

class ProjectService;
struct HmiControlDescriptor;
@@ -104,6 +106,9 @@ public:
*/
HmiEditorResult removeControl(
const std::string &page_id, const std::string &control_id);
HmiEditorResult removeControls(
const std::string &page_id,
const std::vector<std::string> &control_ids);
/**
* @brief 更新控件位置和尺寸
* @param page_id 所属页面唯一标识
@@ -129,7 +134,28 @@ public:
const std::string &control_id,
const HmiControl &control);

bool canUndo() const;
bool canRedo() const;
HmiEditorResult undo();
HmiEditorResult redo();
void clearHistory();

private:
struct HistoryState
{
std::vector<HmiPage> pages;
std::string initial_page_id;
};

HistoryState captureState() const;
void recordHistory(HistoryState before);
static bool statesEqual(
const HistoryState &left, const HistoryState &right);
static bool controlsEqual(
const HmiControl &left, const HmiControl &right);
static bool pagesEqual(const HmiPage &left, const HmiPage &right);
static HmiEditorResult historyFailure(const std::string &message);

/**
* @brief 校验控件编辑后仍满足页面内的基础约束
* @param page 控件所属页面
@@ -173,4 +199,5 @@ private:
* @brief 非拥有的工程服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
EditorHistory<HistoryState> history_;
};

+ 332
- 11
app/src/services/logic_editor_service.cpp Visa fil

@@ -263,6 +263,183 @@ LogicEditorService::LogicEditorService(ProjectService &project_service)
{
}

LogicEditorService::HistoryState LogicEditorService::captureState() const
{
return {project_service_.project().controlLogics};
}

void LogicEditorService::recordHistory(HistoryState before)
{
const HistoryState after = captureState();
history_.record(std::move(before), after, &LogicEditorService::statesEqual);
}

bool LogicEditorService::statesEqual(
const HistoryState &left, const HistoryState &right)
{
if (left.logics.size() != right.logics.size())
{
return false;
}
for (std::size_t index = 0; index < left.logics.size(); ++index)
{
if (!logicsEqual(left.logics[index], right.logics[index]))
{
return false;
}
}
return true;
}

bool LogicEditorService::logicsEqual(
const ControlLogic &left, const ControlLogic &right)
{
if (left.id != right.id || left.name != right.name
|| left.enabled != right.enabled || left.rungs.size() != right.rungs.size())
{
return false;
}
for (std::size_t index = 0; index < left.rungs.size(); ++index)
{
if (!rungsEqual(left.rungs[index], right.rungs[index]))
{
return false;
}
}
return true;
}

bool LogicEditorService::rungsEqual(
const LadderRung &left, const LadderRung &right)
{
if (left.id != right.id || left.name != right.name
|| left.comment != right.comment
|| left.condition.has_value() != right.condition.has_value()
|| left.output.has_value() != right.output.has_value())
{
return false;
}
return (!left.condition.has_value()
|| expressionsEqual(*left.condition, *right.condition))
&& (!left.output.has_value() || nodesEqual(*left.output, *right.output));
}

bool LogicEditorService::expressionsEqual(
const ConditionExpression &left, const ConditionExpression &right)
{
if (left.id != right.id || left.kind != right.kind
|| left.node.has_value() != right.node.has_value()
|| left.children.size() != right.children.size())
{
return false;
}
if (left.node.has_value() && !nodesEqual(*left.node, *right.node))
{
return false;
}
for (std::size_t index = 0; index < left.children.size(); ++index)
{
if (!expressionsEqual(left.children[index], right.children[index]))
{
return false;
}
}
return true;
}

bool LogicEditorService::nodesEqual(
const LogicNode &left, const LogicNode &right)
{
return left.id == right.id && left.config.index() == right.config.index()
&& left.configured == right.configured && configsEqual(left.config, right.config);
}

bool LogicEditorService::configsEqual(
const LogicNodeConfig &left, const LogicNodeConfig &right)
{
return std::visit(
[](const auto &left_config, const auto &right_config)
{
using Left = std::decay_t<decltype(left_config)>;
using Right = std::decay_t<decltype(right_config)>;
if constexpr (!std::is_same_v<Left, Right>)
{
return false;
}
else if constexpr (std::is_same_v<Left, ContactNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.mode == right_config.mode;
}
else if constexpr (std::is_same_v<Left, EdgeContactNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.mode == right_config.mode;
}
else if constexpr (std::is_same_v<Left, TimerContactNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.mode == right_config.mode;
}
else if constexpr (std::is_same_v<Left, CounterContactNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.mode == right_config.mode;
}
else if constexpr (std::is_same_v<Left, CoilNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.mode == right_config.mode;
}
else if constexpr (std::is_same_v<Left, CompareNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.comparison == right_config.comparison
&& left_config.value == right_config.value;
}
else if constexpr (std::is_same_v<Left, TonNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.presetMs == right_config.presetMs;
}
else if constexpr (std::is_same_v<Left, CounterNodeConfig>)
{
return left_config.address == right_config.address
&& left_config.mode == right_config.mode
&& left_config.currentValueAddress == right_config.currentValueAddress
&& left_config.preset.kind == right_config.preset.kind
&& left_config.preset.address == right_config.preset.address
&& left_config.preset.constant == right_config.preset.constant
&& left_config.resetAddress == right_config.resetAddress;
}
else if constexpr (std::is_same_v<Left, MoveNodeConfig>)
{
return left_config.source.kind == right_config.source.kind
&& left_config.source.address == right_config.source.address
&& left_config.source.constant == right_config.source.constant
&& left_config.destination == right_config.destination;
}
else
{
return left_config.operation == right_config.operation
&& left_config.left.kind == right_config.left.kind
&& left_config.left.address == right_config.left.address
&& left_config.left.constant == right_config.left.constant
&& left_config.right.kind == right_config.right.kind
&& left_config.right.address == right_config.right.address
&& left_config.right.constant == right_config.right.constant
&& left_config.destination == right_config.destination;
}
},
left,
right);
}

LogicEditorResult LogicEditorService::historyFailure(const std::string &message)
{
return {false, LogicEditorError::InvalidOperation, message, {}};
}

const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const
{
const auto &logics = project_service_.project().controlLogics;
@@ -394,8 +571,10 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name)
logic.id = makeUniqueLogicId(current);
logic.name = name;
logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt});
HistoryState before = captureState();
Project &project = project_service_.editProject();
project.controlLogics.push_back(std::move(logic));
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, project.controlLogics.back().id};
}

@@ -425,11 +604,17 @@ LogicEditorResult LogicEditorService::renameLogic(
return failure(LogicEditorError::DuplicateName, "控制逻辑名称必须唯一");
}

if (logic->name == name)
{
return {true, LogicEditorError::None, {}, logic_id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto editable = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
editable->name = name;
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, logic_id};
}

@@ -447,12 +632,14 @@ LogicEditorResult LogicEditorService::removeLogic(const std::string &logic_id)
{
return failure(LogicEditorError::LastLogicRequired, "工程至少需要保留一个控制逻辑");
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
project.controlLogics.erase(
std::remove_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }),
project.controlLogics.end());
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, logic_id};
}

@@ -478,10 +665,12 @@ LogicEditorResult LogicEditorService::moveLogic(const std::string &logic_id, int
{
return failure(LogicEditorError::InvalidOperation, "控制逻辑已经位于目标边界");
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
std::iter_swap(
project.controlLogics.begin() + index,
project.controlLogics.begin() + target_index);
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, logic_id};
}

@@ -492,11 +681,18 @@ LogicEditorResult LogicEditorService::setLogicEnabled(
{
return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑");
}
const ControlLogic *existing = findLogic(logic_id);
if (existing->enabled == enabled)
{
return {true, LogicEditorError::None, {}, logic_id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
logic->enabled = enabled;
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, logic_id};
}

@@ -510,11 +706,13 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id)
LadderRung rung;
rung.id = makeUniqueRungId(*logic);
rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U);
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
target->rungs.push_back(std::move(rung));
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, target->rungs.back().id};
}

@@ -534,6 +732,7 @@ LogicEditorResult LogicEditorService::removeRung(
{
return failure(LogicEditorError::InvalidOperation, "控制逻辑至少需要保留一个网络");
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
@@ -543,6 +742,7 @@ LogicEditorResult LogicEditorService::removeRung(
target->rungs.begin(), target->rungs.end(),
[&rung_id](const LadderRung &rung) { return rung.id == rung_id; }),
target->rungs.end());
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, rung_id};
}

@@ -556,9 +756,15 @@ LogicEditorResult LogicEditorService::updateRungComment(
{
return failure(LogicEditorError::RungNotFound, "未找到梯形图网络");
}
if (rung->comment == comment)
{
return {true, LogicEditorError::None, {}, rung_id};
}
HistoryState before = captureState();
LadderRung &editable = *findEditableRung(
project_service_.editProject(), logic_id, rung_id);
editable.comment = comment;
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, rung_id};
}

@@ -580,6 +786,7 @@ LogicEditorResult LogicEditorService::appendCondition(
}
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config));
HistoryState before = captureState();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
if (!rung->condition.has_value())
@@ -598,6 +805,7 @@ LogicEditorResult LogicEditorService::appendCondition(
std::move(*rung->condition),
std::move(leaf));
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

@@ -622,6 +830,7 @@ LogicEditorResult LogicEditorService::insertConditionAfter(
}
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config));
HistoryState before = captureState();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id);
@@ -645,6 +854,7 @@ LogicEditorResult LogicEditorService::insertConditionAfter(
std::move(original),
std::move(leaf));
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

@@ -690,6 +900,7 @@ LogicEditorResult LogicEditorService::addParallelBranch(
{
series_id += "-range";
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
if (!addParallelForSelection(
@@ -703,13 +914,15 @@ LogicEditorResult LogicEditorService::addParallelBranch(
LogicEditorError::InvalidOperation,
"并联选择必须是一个连续的逻辑范围");
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::setOutput(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config)
const LogicNodeConfig &config,
bool configured)
{
const ControlLogic *logic = findLogic(logic_id);
const LadderRung *existing_rung = findRung(logic_id, rung_id);
@@ -721,8 +934,17 @@ LogicEditorResult LogicEditorService::setOutput(
}
const std::string node_id = existing_rung->output.has_value()
? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config));
LogicNode node = makeNode(node_id, config);
node.configured = configured;
if (existing_rung->output.has_value()
&& nodesEqual(*existing_rung->output, node))
{
return {true, LogicEditorError::None, {}, node_id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
findEditableRung(project, logic_id, rung_id)->output = makeNode(node_id, config);
findEditableRung(project, logic_id, rung_id)->output = std::move(node);
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

@@ -748,6 +970,11 @@ LogicEditorResult LogicEditorService::updateNodeConfig(
{
return failure(LogicEditorError::InvalidNode, error);
}
if (nodesEqual(*node, candidate))
{
return {true, LogicEditorError::None, {}, node_id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
for (ControlLogic &logic : project.controlLogics)
{
@@ -760,6 +987,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig(
if (rung.output.has_value() && rung.output->id == node_id)
{
*rung.output = candidate;
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}
if (rung.condition.has_value())
@@ -767,6 +995,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig(
if (LogicNode *editable = findConditionNode(*rung.condition, node_id))
{
*editable = candidate;
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}
}
@@ -778,19 +1007,72 @@ LogicEditorResult LogicEditorService::updateNodeConfig(
LogicEditorResult LogicEditorService::removeNode(
const std::string &logic_id, const std::string &node_id)
{
const std::string rung_id = rungIdForNode(logic_id, node_id);
if (rung_id.empty())
return removeNodes(logic_id, {node_id});
}

LogicEditorResult LogicEditorService::removeNodes(
const std::string &logic_id,
const std::vector<std::string> &node_ids)
{
if (findLogic(logic_id) == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点");
return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑");
}
const LadderRung *rung = findRung(logic_id, rung_id);
if (rung->output.has_value() && rung->output->id == node_id)
if (node_ids.empty())
{
Project &project = project_service_.editProject();
findEditableRung(project, logic_id, rung_id)->output.reset();
return {true, LogicEditorError::None, {}, node_id};
return failure(LogicEditorError::InvalidOperation, "请先选择要删除的逻辑节点");
}
return removeExpression(logic_id, rung_id, node_id);
std::unordered_set<std::string> selected_ids;
for (const std::string &node_id : node_ids)
{
if (!selected_ids.insert(node_id).second)
{
return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复节点");
}
if (findNode(logic_id, node_id) == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点");
}
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
ControlLogic *editable_logic = nullptr;
for (ControlLogic &candidate : project.controlLogics)
{
if (candidate.id == logic_id)
{
editable_logic = &candidate;
break;
}
}
for (LadderRung &rung : editable_logic->rungs)
{
if (rung.output.has_value()
&& selected_ids.find(rung.output->id) != selected_ids.end())
{
rung.output.reset();
}
if (rung.condition.has_value())
{
for (const std::string &node_id : node_ids)
{
if (rung.condition->kind == ConditionExpressionKind::Node
&& rung.condition->id == node_id)
{
rung.condition.reset();
break;
}
removeExpressionRecursive(&*rung.condition, node_id);
}
if (rung.condition.has_value())
{
normalizeConditionExpression(&rung.condition);
}
}
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_ids.front()};
}

LogicEditorResult LogicEditorService::removeExpression(
@@ -802,6 +1084,7 @@ LogicEditorResult LogicEditorService::removeExpression(
{
return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路");
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
if (rung->condition->id == expression_id)
@@ -813,9 +1096,47 @@ LogicEditorResult LogicEditorService::removeExpression(
removeExpressionRecursive(&*rung->condition, expression_id);
normalizeConditionExpression(&rung->condition);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, expression_id};
}

bool LogicEditorService::canUndo() const
{
return history_.canUndo();
}

bool LogicEditorService::canRedo() const
{
return history_.canRedo();
}

LogicEditorResult LogicEditorService::undo()
{
const std::optional<HistoryState> target = history_.undo(captureState());
if (!target.has_value())
{
return historyFailure("没有可撤销的梯形图编辑操作");
}
project_service_.editProject().controlLogics = target->logics;
return {true, LogicEditorError::None, {}, {}};
}

LogicEditorResult LogicEditorService::redo()
{
const std::optional<HistoryState> target = history_.redo(captureState());
if (!target.has_value())
{
return historyFailure("没有可重做的梯形图编辑操作");
}
project_service_.editProject().controlLogics = target->logics;
return {true, LogicEditorError::None, {}, {}};
}

void LogicEditorService::clearHistory()
{
history_.clear();
}

bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config)
{
return std::holds_alternative<ContactNodeConfig>(config)


+ 33
- 1
app/src/services/logic_editor_service.h Visa fil

@@ -1,6 +1,7 @@
#pragma once

#include "domain/control_logic_model.h"
#include "editor_history.h"

#include <string>
#include <vector>
@@ -80,19 +81,49 @@ public:
LogicEditorResult setOutput(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config);
const LogicNodeConfig &config,
bool configured = false);
LogicEditorResult updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config);
LogicEditorResult removeNode(
const std::string &logic_id, const std::string &node_id);
LogicEditorResult removeNodes(
const std::string &logic_id,
const std::vector<std::string> &node_ids);
LogicEditorResult removeExpression(
const std::string &logic_id,
const std::string &rung_id,
const std::string &expression_id);

bool canUndo() const;
bool canRedo() const;
LogicEditorResult undo();
LogicEditorResult redo();
void clearHistory();

private:
struct HistoryState
{
std::vector<ControlLogic> logics;
};

HistoryState captureState() const;
void recordHistory(HistoryState before);
static bool statesEqual(
const HistoryState &left, const HistoryState &right);
static bool logicsEqual(
const ControlLogic &left, const ControlLogic &right);
static bool rungsEqual(
const LadderRung &left, const LadderRung &right);
static bool expressionsEqual(
const ConditionExpression &left, const ConditionExpression &right);
static bool nodesEqual(const LogicNode &left, const LogicNode &right);
static bool configsEqual(
const LogicNodeConfig &left, const LogicNodeConfig &right);
static LogicEditorResult historyFailure(const std::string &message);

static bool isConditionConfig(const LogicNodeConfig &config);
static bool isOutputConfig(const LogicNodeConfig &config);
static std::string nodePrefix(const LogicNodeConfig &config);
@@ -104,4 +135,5 @@ private:
LogicEditorError error, const std::string &message);

ProjectService &project_service_;
EditorHistory<HistoryState> history_;
};

+ 28
- 4
app/src/ui/hmi_editor_widget.cpp Visa fil

@@ -751,6 +751,7 @@ void HmiEditorWidget::reloadPage()
// 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来
void HmiEditorWidget::selectControl(const std::string &control_id)
{
scene_->clearSelection();
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
@@ -765,16 +766,39 @@ void HmiEditorWidget::selectControl(const std::string &control_id)

std::string HmiEditorWidget::selectedControlId() const
{
const QList<QGraphicsItem *> selected = scene_->selectedItems();
for (QGraphicsItem *item : selected)
const std::vector<std::string> ids = selectedControlIds();
return ids.empty() ? std::string{} : ids.front();
}

std::vector<std::string> HmiEditorWidget::selectedControlIds() const
{
std::vector<std::pair<QPointF, std::string>> positioned_ids;
for (QGraphicsItem *item : scene_->selectedItems())
{
const HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr)
{
return control_item->controlId();
positioned_ids.emplace_back(
control_item->scenePos(), control_item->controlId());
}
}
return {};
std::sort(
positioned_ids.begin(), positioned_ids.end(),
[](const auto &left, const auto &right)
{
if (!qFuzzyCompare(left.first.y(), right.first.y()))
{
return left.first.y() < right.first.y();
}
return left.first.x() < right.first.x();
});
std::vector<std::string> ids;
ids.reserve(positioned_ids.size());
for (const auto &positioned_id : positioned_ids)
{
ids.push_back(positioned_id.second);
}
return ids;
}

void HmiEditorWidget::refreshRuntimeValues()


+ 2
- 0
app/src/ui/hmi_editor_widget.h Visa fil

@@ -10,6 +10,7 @@
#include <QGraphicsView>

#include <string>
#include <vector>

class HmiEditorService;
class HmiRuntimeService;
@@ -69,6 +70,7 @@ public:
*/
void selectControl(const std::string &control_id);
std::string selectedControlId() const;
std::vector<std::string> selectedControlIds() const;
/**
* @brief 从统一寄存器仓库读取当前值并刷新画布显示
*


+ 4
- 10
app/src/ui/logic_editor_widget.cpp Visa fil

@@ -1085,10 +1085,11 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co
return result;
}

LogicEditorResult LogicEditorWidget::setOutput(const LogicNodeConfig &config)
LogicEditorResult LogicEditorWidget::setOutput(
const LogicNodeConfig &config, bool configured)
{
const LogicEditorResult result = editor_service_.setOutput(
logic_id_, currentRungId(), config);
logic_id_, currentRungId(), config, configured);
if (result.succeeded)
{
reloadLogic();
@@ -1108,14 +1109,7 @@ LogicEditorResult LogicEditorWidget::deleteSelected()
LogicEditorResult result;
if (!node_ids.empty())
{
for (const std::string &node_id : node_ids)
{
result = editor_service_.removeNode(logic_id_, node_id);
if (!result.succeeded)
{
break;
}
}
result = editor_service_.removeNodes(logic_id_, node_ids);
}
else
{


+ 2
- 1
app/src/ui/logic_editor_widget.h Visa fil

@@ -39,7 +39,8 @@ public:
LogicEditorResult addRung();
LogicEditorResult addCondition(const LogicNodeConfig &config);
LogicEditorResult addParallelBranch(const LogicNodeConfig &config);
LogicEditorResult setOutput(const LogicNodeConfig &config);
LogicEditorResult setOutput(
const LogicNodeConfig &config, bool configured = false);
LogicEditorResult deleteSelected();

signals:


+ 192
- 10
app/src/ui/main_window.cpp Visa fil

@@ -24,20 +24,53 @@
#include "ui_main_window.h"

#include <QActionGroup>
#include <QAbstractSpinBox>
#include <QApplication>
#include <QEvent>
#include <QFileDialog>
#include <QGraphicsScene>
#include <QInputDialog>
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QKeyEvent>
#include <QKeySequence>
#include <QMessageBox>
#include <QMenu>
#include <QPlainTextEdit>
#include <QStatusBar>
#include <QTabWidget>
#include <QToolBar>
#include <QToolButton>
#include <QTextEdit>

namespace {

bool isTextEditingObject(QObject *object)
{
QWidget *widget = qobject_cast<QWidget *>(object);
while (widget != nullptr)
{
if (qobject_cast<QLineEdit *>(widget) != nullptr
|| qobject_cast<QTextEdit *>(widget) != nullptr
|| qobject_cast<QPlainTextEdit *>(widget) != nullptr
|| qobject_cast<QAbstractSpinBox *>(widget) != nullptr)
{
return true;
}
widget = widget->parentWidget();
}
return false;
}

bool isEditorShortcut(const QKeyEvent &event)
{
return event.matches(QKeySequence::Undo)
|| event.matches(QKeySequence::Redo)
|| (event.modifiers() == Qt::NoModifier
&& (event.key() == Qt::Key_Delete || event.key() == Qt::Key_Escape));
}

QString modeText(ApplicationMode mode)
{
switch (mode)
@@ -220,6 +253,7 @@ MainWindow::MainWindow(
void MainWindow::initializeUi()
{
ui_->setupUi(this);
qApp->installEventFilter(this);
configureAppearance();
property_panel_controller_ = std::make_unique<PropertyPanelController>(
*this,
@@ -253,6 +287,7 @@ void MainWindow::initializeUi()
[this] { schedulePlcStatusUpdate(); });
hmi_editor_service_.ensureDefaultPage();
logic_editor_service_.ensureDefaultLogic();
clearEditorHistories();
current_hmi_page_id_ = hmi_editor_service_.firstPageId();
current_logic_id_ = logic_editor_service_.firstLogicId();
refreshProjectUi();
@@ -261,9 +296,25 @@ void MainWindow::initializeUi()

MainWindow::~MainWindow()
{
qApp->removeEventFilter(this);
runtime_mode_service_.setPlcStatusChangedCallback({});
}

bool MainWindow::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::ShortcutOverride
&& isTextEditingObject(watched))
{
auto *key_event = static_cast<QKeyEvent *>(event);
if (isEditorShortcut(*key_event))
{
event->accept();
return true;
}
}
return QMainWindow::eventFilter(watched, event);
}

const std::string &MainWindow::currentHmiPageId() const
{
return current_hmi_page_id_;
@@ -293,6 +344,14 @@ void MainWindow::configureActions()
connect(ui_->saveProjectAction, &QAction::triggered, this, &MainWindow::saveProject);
connect(ui_->saveAsProjectAction, &QAction::triggered, this, &MainWindow::saveProjectAs);
connect(ui_->loadProjectAction, &QAction::triggered, this, &MainWindow::loadProject);
connect(ui_->undoAction, &QAction::triggered,
this, &MainWindow::undoActiveEditor);
connect(ui_->redoAction, &QAction::triggered,
this, &MainWindow::redoActiveEditor);
connect(ui_->deleteSelectionAction, &QAction::triggered,
this, &MainWindow::deleteActiveSelection);
connect(ui_->clearSelectionAction, &QAction::triggered,
this, &MainWindow::clearActiveSelection);

connect(ui_->addButtonAction, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::Button); });
@@ -636,6 +695,10 @@ void MainWindow::configureAppearance()
ui_->saveProjectAction->setIcon(makeUiIcon(UiIcon::Save));
ui_->saveAsProjectAction->setIcon(makeUiIcon(UiIcon::SaveAs));
ui_->loadProjectAction->setIcon(makeUiIcon(UiIcon::Open));
ui_->undoAction->setIcon(makeUiIcon(UiIcon::Undo));
ui_->redoAction->setIcon(makeUiIcon(UiIcon::Redo));
ui_->deleteSelectionAction->setIcon(makeUiIcon(UiIcon::Delete));
ui_->clearSelectionAction->setIcon(makeUiIcon(UiIcon::ClearList));
ui_->exitAction->setIcon(makeUiIcon(UiIcon::Exit));
ui_->configurePlcAction->setIcon(makeUiIcon(UiIcon::PlcSettings));
ui_->disconnectPlcAction->setIcon(makeUiIcon(UiIcon::Disconnect));
@@ -802,6 +865,10 @@ void MainWindow::configureProjectTree()
[this](const QString &message, int timeout_ms)
{
statusBar()->showMessage(message, timeout_ms);
},
[this]
{
updateEditActions();
});
project_workspace_controller_->configure();
}
@@ -844,9 +911,130 @@ void MainWindow::configureRegisterComments()
});
}

void MainWindow::undoActiveEditor()
{
if (!runtime_mode_service_.policy().allowsProjectEditing)
{
return;
}
if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab)
{
if (!hmi_editor_service_.undo().succeeded)
{
return;
}
selected_control_id_.clear();
showControlProperties({});
refreshProjectUi();
hmi_editor_widget_->reloadPage();
statusBar()->showMessage(tr("已撤销 HMI 编辑操作"), 3000);
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
if (!logic_editor_service_.undo().succeeded)
{
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
logic_editor_widget_->reloadLogic();
statusBar()->showMessage(tr("已撤销梯形图编辑操作"), 3000);
}
updateEditActions();
}

void MainWindow::redoActiveEditor()
{
if (!runtime_mode_service_.policy().allowsProjectEditing)
{
return;
}
if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab)
{
if (!hmi_editor_service_.redo().succeeded)
{
return;
}
selected_control_id_.clear();
showControlProperties({});
refreshProjectUi();
hmi_editor_widget_->reloadPage();
statusBar()->showMessage(tr("已重做 HMI 编辑操作"), 3000);
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
if (!logic_editor_service_.redo().succeeded)
{
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
logic_editor_widget_->reloadLogic();
statusBar()->showMessage(tr("已重做梯形图编辑操作"), 3000);
}
updateEditActions();
}

void MainWindow::deleteActiveSelection()
{
if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab)
{
deleteSelectedControl();
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
deleteSelectedLogicObject();
}
}

void MainWindow::clearActiveSelection()
{
if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab)
{
hmi_editor_widget_->scene()->clearSelection();
selected_control_id_.clear();
showControlProperties({});
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
logic_editor_widget_->scene()->clearSelection();
selected_logic_node_id_.clear();
showLogicNodeProperties({});
}
}

void MainWindow::updateEditActions()
{
const bool editable = runtime_mode_service_.policy().allowsProjectEditing;
const bool hmi_active = ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab;
const bool logic_active =
ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab;
ui_->undoAction->setEnabled(
editable && ((hmi_active && hmi_editor_service_.canUndo())
|| (logic_active && logic_editor_service_.canUndo())));
ui_->redoAction->setEnabled(
editable && ((hmi_active && hmi_editor_service_.canRedo())
|| (logic_active && logic_editor_service_.canRedo())));
ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active));
ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active));
}

void MainWindow::clearEditorHistories()
{
hmi_editor_service_.clearHistory();
logic_editor_service_.clearHistory();
if (ui_->undoAction != nullptr)
{
updateEditActions();
}
}

void MainWindow::refreshProjectUi()
{
project_workspace_controller_->refresh();
updateEditActions();
}

void MainWindow::updateProjectTreeActions()
@@ -979,23 +1167,14 @@ void MainWindow::configureAndSetLogicOutput(const LogicNodeConfig &config)
return;
}
const LogicNodeConfig configured = dialog.config();
LogicEditorResult result = logic_editor_widget_->setOutput(configured);
LogicEditorResult result = logic_editor_widget_->setOutput(configured, true);
if (!result.succeeded)
{
showProjectResult(tr("设置逻辑输出"), fromUtf8(result.message), false);
return;
}
const std::string node_id = result.id;
result = logic_editor_service_.updateNodeConfig(
current_logic_id_, node_id, configured);
if (!result.succeeded)
{
showProjectResult(tr("配置逻辑输出"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_ = node_id;
logic_editor_widget_->reloadLogic();
logic_editor_widget_->selectNode(node_id);
showLogicNodeProperties(node_id);
refreshProjectUi();
statusBar()->showMessage(tr("逻辑输出已配置"), 3000);
@@ -1080,6 +1259,7 @@ void MainWindow::createNewProject()
}
hmi_editor_service_.ensureDefaultPage();
logic_editor_service_.ensureDefaultLogic();
clearEditorHistories();
selected_control_id_.clear();
selected_logic_node_id_.clear();
current_hmi_page_id_ = project_service_.project().initialHmiPageId;
@@ -1131,6 +1311,7 @@ void MainWindow::loadProject()
showProjectResult(tr("加载工程"), fromUtf8(result.message), false);
return;
}
clearEditorHistories();
selected_control_id_.clear();
selected_logic_node_id_.clear();
current_hmi_page_id_ = project_service_.project().initialHmiPageId;
@@ -1285,6 +1466,7 @@ void MainWindow::updateModeUi(const QString &message)
ui_->saveAsProjectAction->setEnabled(policy.allowsProjectEditing);
ui_->loadProjectAction->setEnabled(policy.allowsProjectEditing);
updateProjectTreeActions();
updateEditActions();

mode_status_label_->setText(modeText(mode));
if (mode == ApplicationMode::Editing)


+ 10
- 0
app/src/ui/main_window.h Visa fil

@@ -20,6 +20,7 @@
QT_BEGIN_NAMESPACE
class QAction;
class QActionGroup;
class QEvent;
class QLabel;

namespace Ui {
@@ -91,6 +92,9 @@ public:
const std::string &currentHmiPageId() const;
const std::string &currentLogicId() const;

protected:
bool eventFilter(QObject *watched, QEvent *event) override;

private:
// 配置菜单和工具栏动作
void configureActions();
@@ -107,6 +111,12 @@ private:
void configurePlcConnection();
void configureAlarms();
void configureRegisterComments();
void undoActiveEditor();
void redoActiveEditor();
void deleteActiveSelection();
void clearActiveSelection();
void updateEditActions();
void clearEditorHistories();
// 刷新工程树和当前 HMI 页面信息
void refreshProjectUi();
void updateProjectTreeActions();


+ 44
- 1
app/src/ui/main_window.ui Visa fil

@@ -240,7 +240,17 @@
<addaction name="saveAsProjectAction"/>
<addaction name="loadProjectAction"/>
<addaction name="separator"/>
<addaction name="exitAction"/>
<addaction name="exitAction"/>
</widget>
<widget class="QMenu" name="editMenu">
<property name="title">
<string>编辑(&amp;E)</string>
</property>
<addaction name="undoAction"/>
<addaction name="redoAction"/>
<addaction name="separator"/>
<addaction name="deleteSelectionAction"/>
<addaction name="clearSelectionAction"/>
</widget>
<widget class="QMenu" name="runMenu">
<property name="title">
@@ -260,6 +270,7 @@
<addaction name="configureRegisterCommentsAction"/>
</widget>
<addaction name="fileMenu"/>
<addaction name="editMenu"/>
<addaction name="runMenu"/>
<addaction name="viewMenu"/>
</widget>
@@ -888,21 +899,53 @@
<property name="text">
<string>新建工程</string>
</property>
<property name="shortcut">
<string>Ctrl+N</string>
</property>
</action>
<action name="saveProjectAction">
<property name="text">
<string>保存工程</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="saveAsProjectAction">
<property name="text">
<string>工程另存为</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
</action>
<action name="loadProjectAction">
<property name="text">
<string>加载工程</string>
</property>
<property name="shortcut">
<string>Ctrl+O</string>
</property>
</action>
<action name="undoAction">
<property name="text"><string>撤销</string></property>
<property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property>
<property name="shortcut"><string>Ctrl+Z</string></property>
</action>
<action name="redoAction">
<property name="text"><string>重做</string></property>
<property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property>
<property name="shortcut"><string>Ctrl+Y</string></property>
</action>
<action name="deleteSelectionAction">
<property name="text"><string>删除所选</string></property>
<property name="toolTip"><string>删除当前画布中选中的对象</string></property>
<property name="shortcut"><string>Delete</string></property>
</action>
<action name="clearSelectionAction">
<property name="text"><string>清除选择</string></property>
<property name="toolTip"><string>清除当前画布选择</string></property>
<property name="shortcut"><string>Esc</string></property>
</action>
<action name="configurePlcAction">
<property name="text">


+ 11
- 2
app/src/ui/project_workspace_controller.cpp Visa fil

@@ -56,7 +56,8 @@ ProjectWorkspaceController::ProjectWorkspaceController(
std::function<void(const std::string &)> show_control_properties,
std::function<void(const std::string &)> show_logic_properties,
ResultReporter result_reporter,
StatusReporter status_reporter)
StatusReporter status_reporter,
EditStateChanged edit_state_changed)
: parent_(parent),
ui_(ui),
project_service_(project_service),
@@ -72,7 +73,8 @@ ProjectWorkspaceController::ProjectWorkspaceController(
show_control_properties_(std::move(show_control_properties)),
show_logic_properties_(std::move(show_logic_properties)),
result_reporter_(std::move(result_reporter)),
status_reporter_(std::move(status_reporter))
status_reporter_(std::move(status_reporter)),
edit_state_changed_(std::move(edit_state_changed))
{
}

@@ -310,6 +312,7 @@ void ProjectWorkspaceController::addPage()
ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab);
refresh();
hmi_editor_widget_.reloadPage();
edit_state_changed_();
}

void ProjectWorkspaceController::addLogic()
@@ -331,6 +334,7 @@ void ProjectWorkspaceController::addLogic()
ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab);
refresh();
logic_editor_widget_.reloadLogic();
edit_state_changed_();
}

void ProjectWorkspaceController::renameSelectedItem()
@@ -386,6 +390,7 @@ void ProjectWorkspaceController::renameSelectedItem()
}
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::deleteSelectedItem()
@@ -423,6 +428,7 @@ void ProjectWorkspaceController::deleteSelectedItem()
refresh();
show_control_properties_({});
show_logic_properties_({});
edit_state_changed_();
}

void ProjectWorkspaceController::moveSelectedItem(int offset)
@@ -458,6 +464,7 @@ void ProjectWorkspaceController::moveSelectedItem(int offset)
return;
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::setSelectedPageAsInitial()
@@ -477,6 +484,7 @@ void ProjectWorkspaceController::setSelectedPageAsInitial()
return;
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::toggleSelectedLogicEnabled()
@@ -502,6 +510,7 @@ void ProjectWorkspaceController::toggleSelectedLogicEnabled()
return;
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::reportFailure(


+ 4
- 1
app/src/ui/project_workspace_controller.h Visa fil

@@ -31,6 +31,7 @@ public:
const QString &action, const QString &message, bool succeeded)>;
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
using EditStateChanged = std::function<void()>;

ProjectWorkspaceController(
QWidget &parent,
@@ -48,7 +49,8 @@ public:
std::function<void(const std::string &)> show_control_properties,
std::function<void(const std::string &)> show_logic_properties,
ResultReporter result_reporter,
StatusReporter status_reporter);
StatusReporter status_reporter,
EditStateChanged edit_state_changed);

void configure();
void refresh();
@@ -92,6 +94,7 @@ private:
std::function<void(const std::string &)> show_logic_properties_;
ResultReporter result_reporter_;
StatusReporter status_reporter_;
EditStateChanged edit_state_changed_;

QAction *add_page_action_ = nullptr;
QAction *add_logic_action_ = nullptr;


+ 10
- 3
app/src/ui/property_panel_controller.cpp Visa fil

@@ -21,6 +21,7 @@

#include <cstdint>
#include <utility>
#include <vector>

namespace {

@@ -432,12 +433,18 @@ void PropertyPanelController::addHmiControl(HmiControlType type)

void PropertyPanelController::deleteSelectedControl()
{
if (selected_control_id_.empty())
if (hmi_editor_widget_ == nullptr)
{
return;
}
const HmiEditorResult result = hmi_editor_service_.removeControl(
current_page_id_(), selected_control_id_);
const std::vector<std::string> selected_ids =
hmi_editor_widget_->selectedControlIds();
if (selected_ids.empty())
{
return;
}
const HmiEditorResult result = hmi_editor_service_.removeControls(
current_page_id_(), selected_ids);
if (!result.succeeded)
{
reportFailure(QObject::tr("删除控件"), result.message);


+ 25
- 0
app/src/ui/toolbar_icon_factory.cpp Visa fil

@@ -198,6 +198,31 @@ QPixmap renderIcon(UiIcon icon, int size)
painter.drawPath(folder);
break;
}
case UiIcon::Undo:
case UiIcon::Redo:
{
const bool redo = icon == UiIcon::Redo;
QPainterPath arrow;
if (redo)
{
arrow.moveTo(5, 7);
arrow.cubicTo(12, 3, 20, 7, 20, 15);
arrow.lineTo(20, 19);
painter.drawPath(arrow);
painter.drawLine(QPointF(20, 19), QPointF(16, 15));
painter.drawLine(QPointF(20, 19), QPointF(23, 15));
}
else
{
arrow.moveTo(19, 7);
arrow.cubicTo(12, 3, 4, 7, 4, 15);
arrow.lineTo(4, 19);
painter.drawPath(arrow);
painter.drawLine(QPointF(4, 19), QPointF(1, 15));
painter.drawLine(QPointF(4, 19), QPointF(8, 15));
}
break;
}
case UiIcon::Exit:
{
painter.drawRect(QRectF(4, 3, 10, 18));


+ 2
- 0
app/src/ui/toolbar_icon_factory.h Visa fil

@@ -11,6 +11,8 @@ enum class UiIcon
Save,
SaveAs,
Open,
Undo,
Redo,
Exit,
PlcSettings,
Disconnect,


+ 55
- 0
app/tests/hmi_editor_service_tests.cpp Visa fil

@@ -186,6 +186,60 @@ void testRuntimeUsesRegisterRepository()
"progress bars must read D values through the repository");
}

void testHistoryAndAtomicBatchDelete()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService service(project_service);
const std::string page_id = service.ensureDefaultPage().id;
const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label);
const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label);
const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label);
require(first.succeeded && second.succeeded && third.succeeded,
"controls for history testing must be created");

service.clearHistory();
require(!service.removeControls(page_id, {first.id, "missing-control"}).succeeded,
"batch deletion must reject an unknown control before changing the page");
require(service.findPage(page_id)->controls.size() == 3U
&& !service.canUndo(),
"failed batch deletion must be atomic and leave history unchanged");

require(service.removeControls(page_id, {first.id, second.id}).succeeded,
"batch deletion of valid controls must succeed");
require(service.findPage(page_id)->controls.size() == 1U
&& service.canUndo(),
"valid batch deletion must remove all selected controls in one history step");
require(service.undo().succeeded && service.findPage(page_id)->controls.size() == 3U,
"HMI undo must restore a deleted batch");
require(service.redo().succeeded && service.findPage(page_id)->controls.size() == 1U,
"HMI redo must reapply a deleted batch");

service.clearHistory();
const HmiControl *remaining = service.findControl(page_id, third.id);
require(remaining != nullptr,
"the unselected control must survive a batch deletion");
require(service.moveControl(page_id, third.id, remaining->bounds).succeeded
&& !service.canUndo(),
"a no-op control move must not consume an undo step");

service.clearHistory();
HmiControl candidate = *service.findControl(page_id, third.id);
for (int index = 1; index <= 101; ++index)
{
candidate.bounds.x = index;
require(service.updateControl(page_id, third.id, candidate).succeeded,
"repeated valid HMI edits must succeed");
}
int undo_count = 0;
while (service.undo().succeeded)
{
++undo_count;
}
require(undo_count == 100,
"HMI history must retain exactly the configured 100 most recent steps");
}

void testPageLifecycleAndNavigation()
{
TestProjectStorage storage;
@@ -261,6 +315,7 @@ int main()
{
// 编辑和运行场景分别验证服务层两条独立职责
testControlEditing();
testHistoryAndAtomicBatchDelete();
testRuntimeUsesRegisterRepository();
testPageLifecycleAndNavigation();
}


+ 1
- 0
app/tests/hmi_editor_service_tests.pro Visa fil

@@ -30,6 +30,7 @@ HEADERS += \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/editor_history.h \
../src/services/hmi_editor_service.h \
../src/services/hmi_navigation_service.h \
../src/services/hmi_runtime_service.h

+ 72
- 0
app/tests/logic_editor_service_tests.cpp Visa fil

@@ -226,6 +226,77 @@ void testEdgeTimerNodesAndRungComments()
"edge, T contact, TON and rung comment updates must remain in the model");
}

void testHistoryAndAtomicBatchDelete()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string first_rung_id = service.firstRungId(logic_id);
const LogicEditorResult first = service.appendCondition(
logic_id, first_rung_id, contact(0));
const LogicEditorResult second = service.appendCondition(
logic_id, first_rung_id, contact(1));
const LogicEditorResult second_rung = service.addRung(logic_id);
const LogicEditorResult third = service.appendCondition(
logic_id, second_rung.id, contact(2));
require(first.succeeded && second.succeeded && second_rung.succeeded
&& third.succeeded,
"nodes for history testing must be created");

service.clearHistory();
require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
"batch node deletion must validate every id before changing the logic");
require(service.findNode(logic_id, first.id) != nullptr
&& service.findNode(logic_id, third.id) != nullptr
&& !service.canUndo(),
"failed batch node deletion must be atomic and leave history unchanged");

require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
"valid nodes across multiple rungs must be deleted together");
require(service.findNode(logic_id, first.id) == nullptr
&& service.findNode(logic_id, third.id) == nullptr,
"all selected nodes must be removed by one batch operation");
require(service.undo().succeeded
&& service.findNode(logic_id, first.id) != nullptr
&& service.findNode(logic_id, third.id) != nullptr,
"logic undo must restore a cross-rung batch deletion");
require(service.redo().succeeded
&& service.findNode(logic_id, first.id) == nullptr
&& service.findNode(logic_id, third.id) == nullptr,
"logic redo must reapply a cross-rung batch deletion");

service.clearHistory();
const ControlLogic *logic = service.findLogic(logic_id);
require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
&& !service.canUndo(),
"setting an unchanged logic state must not consume history");

service.clearHistory();
for (int index = 1; index <= 101; ++index)
{
require(service.updateRungComment(
logic_id, first_rung_id, "comment-" + std::to_string(index))
.succeeded,
"repeated valid rung edits must succeed");
}
int undo_count = 0;
while (service.undo().succeeded)
{
++undo_count;
}
require(undo_count == 100,
"logic history must retain exactly the configured 100 most recent steps");

require(service.redo().succeeded,
"logic redo must be available after an undo");
require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
"a new logic edit must succeed after undo");
require(!service.canRedo(),
"a new logic edit must clear the redo history");
(void)second;
}

} // namespace

int main()
@@ -236,6 +307,7 @@ int main()
testRangeParallelInsertion();
testLogicLifecycleAndOrdering();
testEdgeTimerNodesAndRungComments();
testHistoryAndAtomicBatchDelete();
}
catch (const std::exception &error)
{


+ 1
- 0
app/tests/logic_editor_service_tests.pro Visa fil

@@ -28,4 +28,5 @@ HEADERS += \
../src/domain/alarm_model.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/editor_history.h \
../src/services/logic_editor_service.h

+ 83
- 0
app/tests/main_window_tests.cpp Visa fil

@@ -603,6 +603,12 @@ void testModeActionsControlEditingAvailability()
QAction *add_progress_bar_action = requiredChild<QAction>(
window, "addProgressBarAction");
QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
QAction *undo_action = requiredChild<QAction>(window, "undoAction");
QAction *redo_action = requiredChild<QAction>(window, "redoAction");
QAction *delete_selection_action = requiredChild<QAction>(
window, "deleteSelectionAction");
QAction *toggle_logic_enabled_action = requiredChild<QAction>(
window, "toggleLogicEnabledAction");
QToolButton *hmi_more_controls = requiredChild<QToolButton>(
window, "hmiMoreControlsButton");
QToolButton *logic_contact_menu = requiredChild<QToolButton>(
@@ -639,12 +645,15 @@ void testModeActionsControlEditingAvailability()
window, "applyPropertiesButton");
HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
window, "hmiEditorWidget");
LogicEditorWidget *logic_editor = requiredChild<LogicEditorWidget>(
window, "logicEditorWidget");
QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget");
QTreeWidget *project_tree = requiredChild<QTreeWidget>(window, "projectTree");
require(!runtime_tab->isVisible(),
"runtime monitor workspace must be hidden while editing");
for (const char *action_name : {
@@ -653,6 +662,10 @@ void testModeActionsControlEditingAvailability()
"saveProjectAction",
"saveAsProjectAction",
"loadProjectAction",
"undoAction",
"redoAction",
"deleteSelectionAction",
"clearSelectionAction",
"configurePlcAction",
"configureRegisterCommentsAction",
"disconnectPlcAction",
@@ -739,9 +752,30 @@ void testModeActionsControlEditingAvailability()
"HMI page must refit when the window becomes larger");

require(editing_action->isChecked(), "editing action must be selected initially");
require(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z)
&& redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y)
&& delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete),
"core editor actions must expose the agreed keyboard shortcuts");
require(project_dock->isEnabled(), "project dock must be enabled while editing");
require(properties_dock->isEnabled(), "properties dock must be enabled while editing");

const std::string default_logic_id = logic_editor_service.firstLogicId();
project_tree->setCurrentItem(project_tree->topLevelItem(1)->child(0));
QApplication::processEvents();
const bool default_logic_enabled =
logic_editor_service.findLogic(default_logic_id)->enabled;
toggle_logic_enabled_action->trigger();
require(undo_action->isEnabled()
&& logic_editor_service.findLogic(default_logic_id)->enabled
!= default_logic_enabled,
"project-tree edits must immediately enable ladder undo");
undo_action->trigger();
require(logic_editor_service.findLogic(default_logic_id)->enabled
== default_logic_enabled,
"ladder undo must restore a project-tree edit");
editor_tabs->setCurrentIndex(0);
QApplication::processEvents();

add_button_action->trigger();
const std::string page_id = editor_service.firstPageId();
require(editor_service.findPage(page_id)->controls.size() == 1,
@@ -753,6 +787,14 @@ void testModeActionsControlEditingAvailability()
require(button_operation->currentText() == QStringLiteral("瞬时 ON"),
"new HMI buttons must default to momentary ON");

text_edit->setFocus();
text_edit->selectAll();
QTest::keyClicks(text_edit, "modified");
QTest::keyClick(text_edit, Qt::Key_Z, Qt::ControlModifier);
require(text_edit->text() == QStringLiteral("按钮")
&& editor_service.findControl(page_id, "button-1") != nullptr,
"Ctrl+Z in a property input must undo text without undoing the HMI model");

const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
"an unbound HMI control must not reserve an address label area");
@@ -777,6 +819,28 @@ void testModeActionsControlEditingAvailability()
require(editor_service.findPage(page_id)->controls.empty(),
"deleting a selected control must update the HMI page model");

QAction *add_label_action = requiredChild<QAction>(window, "addLabelAction");
add_label_action->trigger();
add_label_action->trigger();
add_label_action->trigger();
hmi_editor->scene()->clearSelection();
for (QGraphicsItem *item : hmi_editor->scene()->items())
{
if (item->zValue() >= 0.0)
{
item->setSelected(true);
}
}
delete_selection_action->trigger();
require(editor_service.findPage(page_id)->controls.empty(),
"Delete must remove every selected HMI control in one operation");
undo_action->trigger();
require(editor_service.findPage(page_id)->controls.size() == 3U,
"HMI undo must restore a batch deletion");
redo_action->trigger();
require(editor_service.findPage(page_id)->controls.empty(),
"HMI redo must reapply a batch deletion");

add_progress_bar_action->trigger();
const HmiControl *default_progress = editor_service.findControl(
page_id, "progress-bar-1");
@@ -817,6 +881,22 @@ void testModeActionsControlEditingAvailability()
hmi_editor->reloadPage();

add_normally_open_action->trigger();
editor_tabs->setCurrentIndex(1);
QApplication::processEvents();
undo_action->trigger();
require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
->rungs.front().condition.has_value() == false,
"logic undo must use the ladder history when the ladder tab is active");
redo_action->trigger();
const LadderRung &restored_rung = logic_editor_service.findLogic(
logic_editor_service.firstLogicId())->rungs.front();
require(restored_rung.condition.has_value(),
"logic redo must restore the ladder edit");
std::vector<const LogicNode *> restored_nodes;
collectConditionNodes(*restored_rung.condition, &restored_nodes);
require(restored_nodes.size() == 1U,
"logic redo must restore the original condition node");
logic_editor->selectNode(restored_nodes.front()->id);
parallel_insert_action->trigger();
add_normal_coil_action->trigger();
const ControlLogic *logic = logic_editor_service.findLogic(
@@ -872,6 +952,9 @@ void testModeActionsControlEditingAvailability()
"ProgressBar creation must be disabled while running");
require(!add_normally_open_action->isEnabled(),
"logic add nodes must be disabled while running");
require(!undo_action->isEnabled() && !redo_action->isEnabled()
&& !delete_selection_action->isEnabled(),
"undo, redo and delete must be disabled while running");
require(runtime_tab->isVisible() && runtime_hmi->isVisible()
&& runtime_logic->isVisible() && free_monitor->isVisible(),
"offline running must show HMI, ladder trace and free monitor together");


+ 1
- 0
app/tests/main_window_tests.pro Visa fil

@@ -72,6 +72,7 @@ HEADERS += \
../src/domain/project_storage.h \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/editor_history.h \
../src/services/alarm_editor_service.h \
../src/services/alarm_service.h \
../src/services/hmi_editor_service.h \


Laddar…
Avbryt
Spara