Pārlūkot izejas kodu

feat: 完成控制逻辑图形编辑与持久化

main
suyu pirms 1 mēnesi
vecāks
revīzija
3eaac2a384
19 mainītis faili ar 2057 papildinājumiem un 20 dzēšanām
  1. +6
    -2
      app/integrated_platform.pro
  2. +162
    -0
      app/src/domain/control_logic_model.cpp
  3. +16
    -0
      app/src/domain/control_logic_model.h
  4. +46
    -3
      app/src/infrastructure/json_project_storage.cpp
  5. +3
    -0
      app/src/main.cpp
  6. +395
    -0
      app/src/services/logic_editor_service.cpp
  7. +74
    -0
      app/src/services/logic_editor_service.h
  8. +624
    -0
      app/src/ui/logic_editor_widget.cpp
  9. +64
    -0
      app/src/ui/logic_editor_widget.h
  10. +392
    -11
      app/src/ui/main_window.cpp
  11. +37
    -0
      app/src/ui/main_window.h
  12. +49
    -0
      app/tests/domain_tests.cpp
  13. +102
    -0
      app/tests/logic_editor_service_tests.cpp
  14. +27
    -0
      app/tests/logic_editor_service_tests.pro
  15. +23
    -1
      app/tests/main_window_tests.cpp
  16. +4
    -0
      app/tests/main_window_tests.pro
  17. +5
    -0
      app/tests/project_management_tests.cpp
  18. +18
    -3
      docs/ai/handoff.md
  19. +10
    -0
      docs/architecture.md

+ 6
- 2
app/integrated_platform.pro Parādīt failu

@@ -25,10 +25,12 @@ SOURCES += \
src/domain/runtime_state.cpp \
src/services/project_service.cpp \
src/services/hmi_editor_service.cpp \
src/services/logic_editor_service.cpp \
src/services/hmi_runtime_service.cpp \
src/services/runtime_mode_service.cpp \
src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp
src/ui/hmi_editor_widget.cpp \
src/ui/logic_editor_widget.cpp

HEADERS += \
src/ui/main_window.h \
@@ -41,10 +43,12 @@ HEADERS += \
src/domain/project_storage.h \
src/services/project_service.h \
src/services/hmi_editor_service.h \
src/services/logic_editor_service.h \
src/services/hmi_runtime_service.h \
src/services/runtime_mode_service.h \
src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.h
src/ui/hmi_editor_widget.h \
src/ui/logic_editor_widget.h

FORMS += \
src/ui/main_window.ui

+ 162
- 0
app/src/domain/control_logic_model.cpp Parādīt failu

@@ -1,6 +1,8 @@
#include "control_logic_model.h"

#include <algorithm>
#include <unordered_map>
#include <unordered_set>

namespace {

@@ -86,6 +88,12 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)

} // namespace

bool LogicPoint::isValid() const
{
return x >= kMinimumCoordinate && x <= kMaximumCoordinate
&& y >= kMinimumCoordinate && y <= kMaximumCoordinate;
}

bool LogicNode::validate(std::string *error) const
{
if (id.empty())
@@ -93,6 +101,11 @@ bool LogicNode::validate(std::string *error) const
setError(error, "logic node id must not be empty");
return false;
}
if (!position.isValid())
{
setError(error, "logic node position is outside the supported range");
return false;
}

// 根据 variant 中实际保存的配置类型调用对应校验函数
return std::visit(
@@ -115,6 +128,81 @@ bool LogicConnection::validate(std::string *error) const
}

bool ControlLogic::validate(std::string *error) const
{
if (!validateStructure(error))
{
return false;
}
if (id.empty() || name.empty())
{
setError(error, "control logic id and name must not be empty");
return false;
}

if (nodes.empty())
{
return true;
}

std::unordered_map<std::string, std::size_t> incoming_count;
for (const LogicNode &node : nodes)
{
incoming_count.emplace(node.id, 0U);
}
for (const LogicConnection &connection : connections)
{
++incoming_count.at(connection.toNodeId);
}

// 线圈必须由至少一个条件路径驱动,避免孤立线圈在执行时意外写入 M 位
for (const LogicNode &node : nodes)
{
if (std::holds_alternative<CoilNodeConfig>(node.config)
&& incoming_count.at(node.id) == 0U)
{
setError(error, "coil node must have at least one input connection");
return false;
}
}

// 从所有线圈沿反向连接遍历,一次找出能够产生动作的全部条件节点
std::unordered_map<std::string, std::vector<std::string>> incoming;
std::vector<std::string> pending;
for (const LogicNode &node : nodes)
{
incoming.emplace(node.id, std::vector<std::string>{});
if (std::holds_alternative<CoilNodeConfig>(node.config))
{
pending.push_back(node.id);
}
}
for (const LogicConnection &connection : connections)
{
incoming.at(connection.toNodeId).push_back(connection.fromNodeId);
}
std::unordered_set<std::string> reaches_coil;
while (!pending.empty())
{
const std::string current = pending.back();
pending.pop_back();
if (!reaches_coil.insert(current).second)
{
continue;
}
for (const std::string &previous : incoming.at(current))
{
pending.push_back(previous);
}
}
if (reaches_coil.size() != nodes.size())
{
setError(error, "condition node must reach a coil node");
return false;
}
return true;
}

bool ControlLogic::validateStructure(std::string *error) const
{
if (id.empty() || name.empty())
{
@@ -155,5 +243,79 @@ bool ControlLogic::validate(std::string *error) const
return false;
}
}

// 同一对节点只能存在一条有向连接,避免执行语义和删除行为产生歧义
for (auto current = connections.cbegin(); current != connections.cend(); ++current)
{
const auto duplicate = std::find_if(
current + 1,
connections.cend(),
[current](const LogicConnection &candidate)
{
return candidate.fromNodeId == current->fromNodeId
&& candidate.toNodeId == current->toNodeId;
});
if (duplicate != connections.cend())
{
setError(error, "logic connections must be unique");
return false;
}
}

std::unordered_map<std::string, std::vector<std::string>> outgoing;
std::unordered_map<std::string, std::size_t> incoming_count;
for (const LogicNode &node : nodes)
{
outgoing.emplace(node.id, std::vector<std::string>{});
incoming_count.emplace(node.id, 0U);
}
for (const LogicConnection &connection : connections)
{
const auto from = std::find_if(
nodes.cbegin(),
nodes.cend(),
[&connection](const LogicNode &node)
{
return node.id == connection.fromNodeId;
});
if (std::holds_alternative<CoilNodeConfig>(from->config))
{
setError(error, "coil node cannot have outgoing connections");
return false;
}
outgoing.at(connection.fromNodeId).push_back(connection.toNodeId);
++incoming_count.at(connection.toNodeId);
}

// 使用迭代拓扑排序校验环路,不改变节点的持久化顺序
std::vector<std::string> pending;
for (const auto &entry : incoming_count)
{
if (entry.second == 0U)
{
pending.push_back(entry.first);
}
}
std::size_t visited_count = 0U;
while (!pending.empty())
{
const std::string current = pending.back();
pending.pop_back();
++visited_count;
for (const std::string &next : outgoing.at(current))
{
std::size_t &count = incoming_count.at(next);
--count;
if (count == 0U)
{
pending.push_back(next);
}
}
}
if (visited_count != nodes.size())
{
setError(error, "logic connections must not contain a cycle");
return false;
}
return true;
}

+ 16
- 0
app/src/domain/control_logic_model.h Parādīt failu

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

// 逻辑节点在编辑画布中的持久化坐标
struct LogicPoint
{
static constexpr int kMinimumCoordinate = 0;
static constexpr int kMaximumCoordinate = 100000;

int x = 0;
int y = 0;

bool isValid() const;
};

// 触点工作方式
enum class ContactMode
{
@@ -72,6 +84,8 @@ struct LogicNode
std::string id;
// 节点自身的业务配置
LogicNodeConfig config;
// 节点在逻辑编辑画布中的位置
LogicPoint position;

// 校验节点配置并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
@@ -105,4 +119,6 @@ struct ControlLogic

// 校验控制逻辑并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
// 校验编辑过程所需的结构规则,允许暂时没有完整输出
bool validateStructure(std::string *error = nullptr) const;
};

+ 46
- 3
app/src/infrastructure/json_project_storage.cpp Parādīt failu

@@ -211,6 +211,38 @@ QJsonObject serializeAddress(const RegisterAddress &address)
return object;
}

QJsonObject serializeLogicPoint(const LogicPoint &position)
{
QJsonObject object;
object.insert(QStringLiteral("x"), position.x);
object.insert(QStringLiteral("y"), position.y);
return object;
}

bool parseLogicPoint(
const QJsonObject &object,
const std::string &context,
LogicPoint *position,
ParseState *state)
{
return readInt(
object,
"x",
context,
LogicPoint::kMinimumCoordinate,
LogicPoint::kMaximumCoordinate,
&position->x,
state)
&& readInt(
object,
"y",
context,
LogicPoint::kMinimumCoordinate,
LogicPoint::kMaximumCoordinate,
&position->y,
state);
}

// 解析寄存器地址并校验区域名称及项目允许的索引范围
bool parseAddress(
const QJsonObject &object,
@@ -719,6 +751,7 @@ QJsonObject serializeLogicNode(const LogicNode &node)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(node.id));
object.insert(QStringLiteral("position"), serializeLogicPoint(node.position));
// std::visit 将不同节点配置统一转换为 config JSON 对象
object.insert(
QStringLiteral("config"),
@@ -814,9 +847,19 @@ bool parseLogicNode(
ParseState *state)
{
QJsonObject config;
return readString(object, "id", context, &node->id, state)
&& readObject(object, "config", context, &config, state)
&& parseNodeConfig(config, context + ".config", &node->config, state);
QJsonObject position;
if (!readString(object, "id", context, &node->id, state)
|| !readObject(object, "config", context, &config, state)
|| !readObject(object, "position", context, &position, state)
|| !parseNodeConfig(config, context + ".config", &node->config, state))
{
return false;
}
return parseLogicPoint(
position,
context + ".position",
&node->position,
state);
}

// 将逻辑节点之间的有向连接序列化为 JSON 对象


+ 3
- 0
app/src/main.cpp Parādīt failu

@@ -11,6 +11,7 @@
#include "infrastructure/json_project_storage.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui/main_window.h"
@@ -25,6 +26,7 @@ int main(int argc, char *argv[])
JsonProjectStorage project_storage;
ProjectService project_service(project_storage);
HmiEditorService hmi_editor_service(project_service);
LogicEditorService logic_editor_service(project_service);
// 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现
VirtualRegisterRepository virtual_register_repository;
HmiRuntimeService hmi_runtime_service(virtual_register_repository);
@@ -33,6 +35,7 @@ int main(int argc, char *argv[])
runtime_mode_service,
project_service,
hmi_editor_service,
logic_editor_service,
hmi_runtime_service);
main_window.show();



+ 395
- 0
app/src/services/logic_editor_service.cpp Parādīt failu

@@ -0,0 +1,395 @@
#include "logic_editor_service.h"

#include "project_service.h"

#include <algorithm>
#include <sstream>
#include <type_traits>
#include <utility>

namespace {

bool validateCandidateNode(
const std::string &id,
const LogicNodeConfig &config,
const LogicPoint &position,
std::string *error)
{
LogicNode candidate;
candidate.id = id;
candidate.config = config;
candidate.position = position;
return candidate.validate(error);
}

} // namespace

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

const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const
{
const Project &project = project_service_.project();
const auto logic = std::find_if(
project.controlLogics.cbegin(),
project.controlLogics.cend(),
[&logic_id](const ControlLogic &candidate)
{
return candidate.id == logic_id;
});
return logic == project.controlLogics.cend() ? nullptr : &*logic;
}

const LogicNode *LogicEditorService::findNode(
const std::string &logic_id, const std::string &node_id) const
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return nullptr;
}
const auto node = std::find_if(
logic->nodes.cbegin(),
logic->nodes.cend(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
});
return node == logic->nodes.cend() ? nullptr : &*node;
}

std::string LogicEditorService::firstLogicId() const
{
const Project &project = project_service_.project();
return project.controlLogics.empty() ? std::string{} : project.controlLogics.front().id;
}

LogicEditorResult LogicEditorService::ensureDefaultLogic()
{
if (!project_service_.project().controlLogics.empty())
{
return {true, LogicEditorError::None, {}, firstLogicId()};
}

ControlLogic logic;
logic.id = "logic-1";
logic.name = "控制逻辑 1";
Project &project = project_service_.editProject();
project.controlLogics.push_back(std::move(logic));
return {true, LogicEditorError::None, {}, project.controlLogics.back().id};
}

LogicEditorResult LogicEditorService::addNode(
const std::string &logic_id,
const LogicNodeConfig &config,
const LogicPoint &position)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}

const std::string id = makeUniqueNodeId(*logic, nodePrefix(config));
std::string error;
if (!validateCandidateNode(id, config, position, &error))
{
return failure(LogicEditorError::InvalidNode, error);
}

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;
});
LogicNode node;
node.id = id;
node.config = config;
node.position = position;
target->nodes.push_back(std::move(node));
return {true, LogicEditorError::None, {}, id};
}

LogicEditorResult LogicEditorService::moveNode(
const std::string &logic_id,
const std::string &node_id,
const LogicPoint &position)
{
const ControlLogic *logic = findLogic(logic_id);
const LogicNode *node = findNode(logic_id, node_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (node == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
}
if (!position.isValid())
{
return failure(
LogicEditorError::InvalidNode,
"logic node position is outside the supported range");
}

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;
});
auto editable = std::find_if(
target->nodes.begin(),
target->nodes.end(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
});
editable->position = position;
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
const LogicNode *node = findNode(logic_id, node_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (node == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
}
if (node->config.index() != config.index())
{
return failure(
LogicEditorError::UnsupportedNodeChange,
"node category cannot be changed after creation");
}

std::string error;
if (!validateCandidateNode(node_id, config, node->position, &error))
{
return failure(LogicEditorError::InvalidNode, error);
}

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;
});
auto editable = std::find_if(
target->nodes.begin(),
target->nodes.end(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
});
editable->config = config;
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::connectNodes(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (findNode(logic_id, from_node_id) == nullptr
|| findNode(logic_id, to_node_id) == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic connection node was not found");
}
if (hasConnection(*logic, from_node_id, to_node_id))
{
return failure(
LogicEditorError::DuplicateConnection,
"logic connection already exists");
}

ControlLogic candidate = *logic;
candidate.connections.push_back({from_node_id, to_node_id});
std::string error;
if (!candidate.validateStructure(&error))
{
return failure(LogicEditorError::InvalidConnection, error);
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &item)
{
return item.id == logic_id;
});
target->connections.push_back({from_node_id, to_node_id});
return {true, LogicEditorError::None, {}, to_node_id};
}

LogicEditorResult LogicEditorService::disconnectNodes(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
const auto connection = std::find_if(
logic->connections.cbegin(),
logic->connections.cend(),
[&from_node_id, &to_node_id](const LogicConnection &candidate)
{
return candidate.fromNodeId == from_node_id
&& candidate.toNodeId == to_node_id;
});
if (connection == logic->connections.cend())
{
return failure(LogicEditorError::InvalidConnection, "logic connection was not found");
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &item)
{
return item.id == logic_id;
});
target->connections.erase(
std::remove_if(
target->connections.begin(),
target->connections.end(),
[&from_node_id, &to_node_id](const LogicConnection &candidate)
{
return candidate.fromNodeId == from_node_id
&& candidate.toNodeId == to_node_id;
}),
target->connections.end());
return {true, LogicEditorError::None, {}, to_node_id};
}

LogicEditorResult LogicEditorService::removeNode(
const std::string &logic_id, const std::string &node_id)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (findNode(logic_id, node_id) == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &item)
{
return item.id == logic_id;
});
target->nodes.erase(
std::remove_if(
target->nodes.begin(),
target->nodes.end(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
}),
target->nodes.end());
target->connections.erase(
std::remove_if(
target->connections.begin(),
target->connections.end(),
[&node_id](const LogicConnection &candidate)
{
return candidate.fromNodeId == node_id || candidate.toNodeId == node_id;
}),
target->connections.end());
return {true, LogicEditorError::None, {}, node_id};
}

std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config)
{
return std::visit(
[](const auto &value) -> std::string
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{
return "contact";
}
if constexpr (std::is_same_v<Config, CoilNodeConfig>)
{
return "coil";
}
return "compare";
},
config);
}

std::string LogicEditorService::makeUniqueNodeId(
const ControlLogic &logic, const std::string &prefix)
{
for (std::size_t index = 1;; ++index)
{
std::ostringstream stream;
stream << prefix << '-' << index;
const std::string candidate = stream.str();
const auto match = std::find_if(
logic.nodes.cbegin(),
logic.nodes.cend(),
[&candidate](const LogicNode &node)
{
return node.id == candidate;
});
if (match == logic.nodes.cend())
{
return candidate;
}
}
}

bool LogicEditorService::hasConnection(
const ControlLogic &logic,
const std::string &from_node_id,
const std::string &to_node_id)
{
return std::find_if(
logic.connections.cbegin(),
logic.connections.cend(),
[&from_node_id, &to_node_id](const LogicConnection &candidate)
{
return candidate.fromNodeId == from_node_id
&& candidate.toNodeId == to_node_id;
})
!= logic.connections.cend();
}

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

+ 74
- 0
app/src/services/logic_editor_service.h Parādīt failu

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

#include "domain/control_logic_model.h"

#include <string>

class ProjectService;

enum class LogicEditorError
{
None,
LogicNotFound,
NodeNotFound,
DuplicateConnection,
InvalidNode,
InvalidConnection,
UnsupportedNodeChange
};

struct LogicEditorResult
{
bool succeeded = false;
LogicEditorError error = LogicEditorError::None;
std::string message;
std::string id;
};

class LogicEditorService
{
public:
explicit LogicEditorService(ProjectService &project_service);

const ControlLogic *findLogic(const std::string &logic_id) const;
const LogicNode *findNode(
const std::string &logic_id, const std::string &node_id) const;
std::string firstLogicId() const;

LogicEditorResult ensureDefaultLogic();
LogicEditorResult addNode(
const std::string &logic_id,
const LogicNodeConfig &config,
const LogicPoint &position);
LogicEditorResult moveNode(
const std::string &logic_id,
const std::string &node_id,
const LogicPoint &position);
LogicEditorResult updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config);
LogicEditorResult connectNodes(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id);
LogicEditorResult disconnectNodes(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id);
LogicEditorResult removeNode(
const std::string &logic_id, const std::string &node_id);

private:
static std::string nodePrefix(const LogicNodeConfig &config);
static std::string makeUniqueNodeId(
const ControlLogic &logic, const std::string &prefix);
static bool hasConnection(
const ControlLogic &logic,
const std::string &from_node_id,
const std::string &to_node_id);
static LogicEditorResult failure(
LogicEditorError error, const std::string &message);

ProjectService &project_service_;
};

+ 624
- 0
app/src/ui/logic_editor_widget.cpp Parādīt failu

@@ -0,0 +1,624 @@
#include "logic_editor_widget.h"

#include "services/logic_editor_service.h"

#include <QGraphicsPathItem>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QResizeEvent>
#include <QStyleOptionGraphicsItem>

#include <algorithm>
#include <cmath>
#include <functional>
#include <map>
#include <type_traits>
#include <utility>

namespace {

constexpr qreal kNodeWidth = 168.0;
constexpr qreal kNodeHeight = 72.0;
constexpr qreal kPortRadius = 6.0;

QString nodeTitle(const LogicNodeConfig &config)
{
return std::visit(
[](const auto &value) -> QString
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{
return value.mode == ContactMode::NormallyOpen
? QStringLiteral("常开触点")
: QStringLiteral("常闭触点");
}
if constexpr (std::is_same_v<Config, CoilNodeConfig>)
{
if (value.mode == CoilMode::Set)
{
return QStringLiteral("置位线圈");
}
if (value.mode == CoilMode::Reset)
{
return QStringLiteral("复位线圈");
}
return QStringLiteral("普通线圈");
}
return QStringLiteral("D 值比较");
},
config);
}

QString nodeDetail(const LogicNodeConfig &config)
{
return std::visit(
[](const auto &value) -> QString
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{
return QStringLiteral("M%1").arg(value.address.index());
}
else if constexpr (std::is_same_v<Config, CoilNodeConfig>)
{
return QStringLiteral("M%1").arg(value.address.index());
}
else
{
return QStringLiteral("D%1 = %2")
.arg(value.address.index())
.arg(value.value);
}
},
config);
}

bool isCoil(const LogicNodeConfig &config)
{
return std::holds_alternative<CoilNodeConfig>(config);
}

QPainterPath makeConnectionPath(const QPointF &from, const QPointF &to)
{
QPainterPath path(from);
const qreal offset = std::max(48.0, std::abs(to.x() - from.x()) * 0.45);
path.cubicTo(
from + QPointF(offset, 0),
to - QPointF(offset, 0),
to);
return path;
}

} // namespace

class LogicEditorWidget::NodeItem final : public QGraphicsItem
{
public:
NodeItem(
const LogicNode &node,
std::function<void(const std::string &, const QPointF &)> moved,
std::function<void(const std::string &)> position_changed)
: node_id_(node.id),
config_(node.config),
moved_(std::move(moved)),
position_changed_(std::move(position_changed))
{
setPos(node.position.x, node.position.y);
setFlag(ItemIsSelectable, true);
setFlag(ItemSendsGeometryChanges, true);
setAcceptedMouseButtons(Qt::LeftButton);
}

QRectF boundingRect() const override
{
return {0, 0, kNodeWidth, kNodeHeight};
}

void paint(
QPainter *painter,
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
const QRectF rect = boundingRect().adjusted(1, 1, -1, -1);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1));
painter->setBrush(isCoil(config_)
? QColor(QStringLiteral("#fff2d8"))
: QColor(QStringLiteral("#e8f1f6")));
painter->drawRoundedRect(rect, 5, 5);

painter->setPen(QColor(QStringLiteral("#18252e")));
painter->drawText(
rect.adjusted(14, 8, -14, -34),
Qt::AlignLeft | Qt::AlignVCenter,
nodeTitle(config_));
painter->setPen(QColor(QStringLiteral("#586873")));
painter->drawText(
rect.adjusted(14, 35, -14, -8),
Qt::AlignLeft | Qt::AlignVCenter,
nodeDetail(config_));

painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1));
painter->setBrush(QColor(QStringLiteral("#ffffff")));
painter->drawEllipse(inputPortRect());
if (!isCoil(config_))
{
painter->drawEllipse(outputPortRect());
}

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

const std::string &nodeId() const
{
return node_id_;
}

bool hasOutput() const
{
return !isCoil(config_);
}

QRectF inputPortRect() const
{
return {0, kNodeHeight / 2.0 - kPortRadius,
2.0 * kPortRadius, 2.0 * kPortRadius};
}

QRectF outputPortRect() const
{
return {kNodeWidth - 2.0 * kPortRadius, kNodeHeight / 2.0 - kPortRadius,
2.0 * kPortRadius, 2.0 * kPortRadius};
}

QPointF inputPortScenePosition() const
{
return mapToScene(inputPortRect().center());
}

QPointF outputPortScenePosition() const
{
return mapToScene(outputPortRect().center());
}

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

protected:
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override
{
if (change == ItemPositionHasChanged && position_changed_)
{
position_changed_(node_id_);
}
return QGraphicsItem::itemChange(change, value);
}

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

private:
std::string node_id_;
LogicNodeConfig config_;
std::function<void(const std::string &, const QPointF &)> moved_;
std::function<void(const std::string &)> position_changed_;
};

class LogicEditorWidget::ConnectionItem final : public QGraphicsPathItem
{
public:
ConnectionItem(
const std::string &from_node_id,
const std::string &to_node_id)
: from_node_id_(from_node_id), to_node_id_(to_node_id)
{
setFlag(ItemIsSelectable, true);
setZValue(-1.0);
setPen(QPen(QColor(QStringLiteral("#5d6f7b")), 2));
}

const std::string &fromNodeId() const
{
return from_node_id_;
}

const std::string &toNodeId() const
{
return to_node_id_;
}

void updatePath(const QPointF &from, const QPointF &to)
{
setPath(makeConnectionPath(from, to));
}

private:
std::string from_node_id_;
std::string to_node_id_;
};

LogicEditorWidget::LogicEditorWidget(
LogicEditorService &editor_service,
QWidget *parent)
: QGraphicsView(parent), editor_service_(editor_service)
{
scene_ = new QGraphicsScene(this);
scene_->setSceneRect(0, 0, 2400, 1400);
setScene(scene_);
setRenderHint(QPainter::Antialiasing, true);
setBackgroundBrush(QColor(QStringLiteral("#ffffff")));
setDragMode(QGraphicsView::RubberBandDrag);
setTransformationAnchor(QGraphicsView::AnchorUnderMouse);
connect(scene_, &QGraphicsScene::selectionChanged,
this, &LogicEditorWidget::handleSelectionChanged);
}

void LogicEditorWidget::setLogicId(const std::string &logic_id)
{
if (logic_id_ == logic_id)
{
return;
}
logic_id_ = logic_id;
reloadLogic();
}

void LogicEditorWidget::setEditingEnabled(bool enabled)
{
editing_enabled_ = enabled;
setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag);
for (QGraphicsItem *item : scene_->items())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr)
{
node->setInteractionEnabled(enabled);
}
}
if (!enabled)
{
clearPreview();
}
}

void LogicEditorWidget::reloadLogic()
{
clearPreview();
scene_->clear();
if (logic_id_.empty())
{
return;
}
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
if (logic == nullptr)
{
return;
}

std::map<std::string, NodeItem *> node_items;
for (const LogicConnection &connection : logic->connections)
{
scene_->addItem(new ConnectionItem(connection.fromNodeId, connection.toNodeId));
}
for (const LogicNode &node : logic->nodes)
{
auto *item = new NodeItem(
node,
[this](const std::string &node_id, const QPointF &position)
{
handleNodePositionChanged(node_id);
const LogicPoint point{
static_cast<int>(std::lround(position.x())),
static_cast<int>(std::lround(position.y()))};
const LogicEditorResult result = editor_service_.moveNode(
logic_id_, node_id, point);
if (!result.succeeded)
{
reportFailure(result);
reloadLogic();
return;
}
emit nodeChanged(QString::fromStdString(node_id));
},
[this](const std::string &)
{
updateConnections();
});
item->setInteractionEnabled(editing_enabled_);
scene_->addItem(item);
node_items.emplace(node.id, item);
}
updateConnections();
}

void LogicEditorWidget::selectNode(const std::string &node_id)
{
for (QGraphicsItem *item : scene_->items())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr)
{
node->setSelected(node->nodeId() == node_id);
if (node->nodeId() == node_id)
{
ensureVisible(node);
}
}
}
}

std::string LogicEditorWidget::selectedNodeId() const
{
for (QGraphicsItem *item : scene_->selectedItems())
{
const NodeItem *node = dynamic_cast<const NodeItem *>(item);
if (node != nullptr)
{
return node->nodeId();
}
}
return {};
}

LogicEditorResult LogicEditorWidget::addNode(const LogicNodeConfig &config)
{
if (logic_id_.empty())
{
const LogicEditorResult logic_result = editor_service_.ensureDefaultLogic();
if (!logic_result.succeeded)
{
reportFailure(logic_result);
return logic_result;
}
logic_id_ = logic_result.id;
}
const LogicEditorResult result = editor_service_.addNode(
logic_id_, config, nextNodePosition());
if (!result.succeeded)
{
reportFailure(result);
return result;
}
reloadLogic();
selectNode(result.id);
emit nodeChanged(QString::fromStdString(result.id));
emit graphChanged();
return result;
}

LogicEditorResult LogicEditorWidget::deleteSelected()
{
for (QGraphicsItem *item : scene_->selectedItems())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr)
{
const LogicEditorResult result = editor_service_.removeNode(
logic_id_, node->nodeId());
if (!result.succeeded)
{
reportFailure(result);
return result;
}
reloadLogic();
emit nodeSelected({});
emit graphChanged();
return result;
}
ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item);
if (connection != nullptr)
{
const LogicEditorResult result = editor_service_.disconnectNodes(
logic_id_, connection->fromNodeId(), connection->toNodeId());
if (!result.succeeded)
{
reportFailure(result);
return result;
}
reloadLogic();
emit graphChanged();
return result;
}
}
return {false, LogicEditorError::NodeNotFound, "no logic object is selected", {}};
}

void LogicEditorWidget::mousePressEvent(QMouseEvent *event)
{
if (editing_enabled_ && event->button() == Qt::LeftButton)
{
NodeItem *output = nodeAtPort(mapToScene(event->pos()), true);
if (output != nullptr)
{
connecting_from_node_id_ = output->nodeId();
preview_item_ = scene_->addPath(QPainterPath());
preview_item_->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2, Qt::DashLine));
preview_item_->setZValue(-0.5);
updatePreview(mapToScene(event->pos()));
event->accept();
return;
}
}
QGraphicsView::mousePressEvent(event);
}

void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event)
{
if (!connecting_from_node_id_.empty())
{
updatePreview(mapToScene(event->pos()));
event->accept();
return;
}
QGraphicsView::mouseMoveEvent(event);
}

void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (!connecting_from_node_id_.empty())
{
NodeItem *input = nodeAtPort(mapToScene(event->pos()), false);
const std::string from = connecting_from_node_id_;
clearPreview();
if (input != nullptr && input->nodeId() != from)
{
const LogicEditorResult result = editor_service_.connectNodes(
logic_id_, from, input->nodeId());
if (!result.succeeded)
{
reportFailure(result);
}
else
{
reloadLogic();
emit graphChanged();
}
}
event->accept();
return;
}
QGraphicsView::mouseReleaseEvent(event);
}

void LogicEditorWidget::resizeEvent(QResizeEvent *event)
{
QGraphicsView::resizeEvent(event);
updateConnections();
}

void LogicEditorWidget::handleSelectionChanged()
{
emit nodeSelected(QString::fromStdString(selectedNodeId()));
}

void LogicEditorWidget::handleNodePositionChanged(const std::string &)
{
updateConnections();
}

void LogicEditorWidget::updateConnections()
{
std::map<std::string, NodeItem *> node_items;
for (QGraphicsItem *item : scene_->items())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr)
{
node_items.emplace(node->nodeId(), node);
}
}
for (QGraphicsItem *item : scene_->items())
{
ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item);
if (connection == nullptr)
{
continue;
}
const auto from = node_items.find(connection->fromNodeId());
const auto to = node_items.find(connection->toNodeId());
if (from != node_items.end() && to != node_items.end())
{
connection->updatePath(
from->second->outputPortScenePosition(),
to->second->inputPortScenePosition());
}
}
}

void LogicEditorWidget::updatePreview(const QPointF &scene_position)
{
if (preview_item_ == nullptr)
{
return;
}
for (QGraphicsItem *item : scene_->items())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr && node->nodeId() == connecting_from_node_id_)
{
preview_item_->setPath(
makeConnectionPath(node->outputPortScenePosition(), scene_position));
return;
}
}
}

void LogicEditorWidget::clearPreview()
{
if (preview_item_ != nullptr)
{
scene_->removeItem(preview_item_);
delete preview_item_;
preview_item_ = nullptr;
}
connecting_from_node_id_.clear();
}

LogicEditorWidget::NodeItem *LogicEditorWidget::nodeAtPort(
const QPointF &scene_position, bool output) const
{
for (QGraphicsItem *item : scene_->items())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node == nullptr || (output && !node->hasOutput()))
{
continue;
}
const QPointF port = output
? node->outputPortScenePosition()
: node->inputPortScenePosition();
if (QLineF(port, scene_position).length() <= 12.0)
{
return node;
}
}
return nullptr;
}

LogicPoint LogicEditorWidget::nextNodePosition() const
{
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
for (std::size_t index = 0;; ++index)
{
const LogicPoint candidate{
80 + static_cast<int>(index % 5U) * 230,
80 + static_cast<int>(index / 5U) * 130};
if (logic == nullptr)
{
return candidate;
}
const auto occupied = std::find_if(
logic->nodes.cbegin(),
logic->nodes.cend(),
[&candidate](const LogicNode &node)
{
return std::abs(node.position.x - candidate.x) < 40
&& std::abs(node.position.y - candidate.y) < 40;
});
if (occupied == logic->nodes.cend())
{
return candidate;
}
}
}

void LogicEditorWidget::reportFailure(const LogicEditorResult &result)
{
emit editorError(QString::fromStdString(result.message));
}

+ 64
- 0
app/src/ui/logic_editor_widget.h Parādīt failu

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

#include "domain/control_logic_model.h"

#include <QGraphicsView>

#include <string>

class LogicEditorService;
struct LogicEditorResult;
class QGraphicsPathItem;
class QGraphicsScene;
class QMouseEvent;
class QResizeEvent;

class LogicEditorWidget final : public QGraphicsView
{
Q_OBJECT

public:
explicit LogicEditorWidget(
LogicEditorService &editor_service,
QWidget *parent = nullptr);

void setLogicId(const std::string &logic_id);
void setEditingEnabled(bool enabled);
void reloadLogic();
void selectNode(const std::string &node_id);
std::string selectedNodeId() const;
LogicEditorResult addNode(const LogicNodeConfig &config);
LogicEditorResult deleteSelected();

signals:
void nodeSelected(const QString &node_id);
void nodeChanged(const QString &node_id);
void graphChanged();
void editorError(const QString &message);

protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void resizeEvent(QResizeEvent *event) override;

private:
class NodeItem;
class ConnectionItem;

void handleSelectionChanged();
void handleNodePositionChanged(const std::string &node_id);
void updateConnections();
void updatePreview(const QPointF &scene_position);
void clearPreview();
NodeItem *nodeAtPort(const QPointF &scene_position, bool output) const;
LogicPoint nextNodePosition() const;
void reportFailure(const LogicEditorResult &result);

LogicEditorService &editor_service_;
QGraphicsScene *scene_ = nullptr;
std::string logic_id_;
bool editing_enabled_ = true;
std::string connecting_from_node_id_;
QGraphicsPathItem *preview_item_ = nullptr;
};

+ 392
- 11
app/src/ui/main_window.cpp Parādīt failu

@@ -1,8 +1,10 @@
#include "main_window.h"

#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui_main_window.h"
@@ -18,13 +20,19 @@
#include <QMessageBox>
#include <QPushButton>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStatusBar>
#include <QStyle>
#include <QTimer>
#include <QToolBar>
#include <QTabWidget>
#include <QTreeWidget>
#include <QTreeWidgetItem>

#include <cstdint>
#include <limits>
#include <type_traits>

namespace {

QString modeText(ApplicationMode mode)
@@ -124,6 +132,7 @@ MainWindow::MainWindow(
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
HmiRuntimeService &hmi_runtime_service,
QWidget *parent)
: QMainWindow(parent),
@@ -131,6 +140,7 @@ MainWindow::MainWindow(
runtime_mode_service_(runtime_mode_service),
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
logic_editor_service_(logic_editor_service),
hmi_runtime_service_(hmi_runtime_service)
{
ui_->setupUi(this);
@@ -138,7 +148,9 @@ MainWindow::MainWindow(
configureActions(); // 创建所有菜单 Action、工具栏按钮,绑定点击槽函数
configurePropertyEditor(); // 动态构建属性表单:ID、文本、坐标宽高、寄存器区域 (M/D)、地址索引 + 应用按钮
configureHmiEditor(); // 创建画布控件HmiEditorWidget嵌入主窗口,绑定画布信号:选中控件、控件拖动修改、编辑器报错;同时启动运行刷新定时器
configureLogicEditor();
hmi_editor_service_.ensureDefaultPage();
logic_editor_service_.ensureDefaultLogic();
refreshProjectUi();
updateModeUi(tr("系统已进入编辑态"));
}
@@ -216,10 +228,112 @@ void MainWindow::configureActions()
connect(delete_control_action_, &QAction::triggered,
this, &MainWindow::deleteSelectedControl);

logic_tool_bar_ = addToolBar(tr("控制逻辑节点"));
logic_tool_bar_->setObjectName(QStringLiteral("logicToolBar"));
logic_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
add_normally_open_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_ArrowRight), tr("常开"));
add_normally_closed_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_ArrowRight), tr("常闭"));
add_normal_coil_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_DialogApplyButton), tr("线圈"));
add_set_coil_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_DialogYesButton), tr("置位"));
add_reset_coil_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_DialogNoButton), tr("复位"));
add_compare_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("D 比较"));
delete_logic_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_TrashIcon), tr("删除"));
add_normally_open_action_->setObjectName(QStringLiteral("addNormallyOpenAction"));
add_normally_closed_action_->setObjectName(QStringLiteral("addNormallyClosedAction"));
add_normal_coil_action_->setObjectName(QStringLiteral("addNormalCoilAction"));
add_set_coil_action_->setObjectName(QStringLiteral("addSetCoilAction"));
add_reset_coil_action_->setObjectName(QStringLiteral("addResetCoilAction"));
add_compare_action_->setObjectName(QStringLiteral("addCompareAction"));
delete_logic_action_->setObjectName(QStringLiteral("deleteLogicAction"));
add_normally_open_action_->setToolTip(tr("添加常开触点"));
add_normally_closed_action_->setToolTip(tr("添加常闭触点"));
add_normal_coil_action_->setToolTip(tr("添加普通线圈"));
add_set_coil_action_->setToolTip(tr("添加置位线圈"));
add_reset_coil_action_->setToolTip(tr("添加复位线圈"));
add_compare_action_->setToolTip(tr("添加 D 值与常量比较"));
delete_logic_action_->setToolTip(tr("删除选中的逻辑节点或连接"));
connect(add_normally_open_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen});
});
connect(add_normally_closed_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyClosed});
});
connect(add_normal_coil_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(CoilNodeConfig{
RegisterAddress{RegisterArea::M, 0},
CoilMode::Normal});
});
connect(add_set_coil_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(CoilNodeConfig{
RegisterAddress{RegisterArea::M, 0},
CoilMode::Set});
});
connect(add_reset_coil_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(CoilNodeConfig{
RegisterAddress{RegisterArea::M, 0},
CoilMode::Reset});
});
connect(add_compare_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(CompareNodeConfig{
RegisterAddress{RegisterArea::D, 0},
ComparisonOperator::Equal,
0});
});
connect(delete_logic_action_, &QAction::triggered,
this, &MainWindow::deleteSelectedLogicObject);

connect(ui_->editorTabWidget, &QTabWidget::currentChanged,
this,
[this](int index)
{
hmi_tool_bar_->setVisible(index == 0);
logic_tool_bar_->setVisible(index == 1);
if (index == 0)
{
showControlProperties(selected_control_id_);
}
else
{
showLogicNodeProperties(selected_logic_node_id_);
}
});
hmi_tool_bar_->setVisible(ui_->editorTabWidget->currentIndex() == 0);
logic_tool_bar_->setVisible(ui_->editorTabWidget->currentIndex() == 1);

ui_->viewMenu->addAction(ui_->projectDock->toggleViewAction());
ui_->viewMenu->addAction(ui_->propertiesDock->toggleViewAction());
ui_->viewMenu->addAction(ui_->outputDock->toggleViewAction());
ui_->viewMenu->addAction(hmi_tool_bar_->toggleViewAction());
ui_->viewMenu->addAction(logic_tool_bar_->toggleViewAction());
ui_->viewMenu->addSeparator();
ui_->viewMenu->addAction(ui_->modeToolBar->toggleViewAction());
}
@@ -306,6 +420,38 @@ void MainWindow::configureHmiEditor()
runtime_refresh_timer_->start();
}

void MainWindow::configureLogicEditor()
{
QLayout *layout = ui_->logicCanvasPlaceholder->layout();
delete ui_->logicEmptyLabel;
logic_editor_widget_ = new LogicEditorWidget(
logic_editor_service_, ui_->logicCanvasPlaceholder);
logic_editor_widget_->setObjectName(QStringLiteral("logicEditorWidget"));
logic_editor_widget_->setMinimumHeight(320);
layout->addWidget(logic_editor_widget_);
connect(logic_editor_widget_, &LogicEditorWidget::nodeSelected,
this,
[this](const QString &id)
{
showLogicNodeProperties(toUtf8(id));
});
connect(logic_editor_widget_, &LogicEditorWidget::nodeChanged,
this,
[this](const QString &id)
{
showLogicNodeProperties(toUtf8(id));
refreshProjectUi();
});
connect(logic_editor_widget_, &LogicEditorWidget::graphChanged,
this, &MainWindow::refreshProjectUi);
connect(logic_editor_widget_, &LogicEditorWidget::editorError,
this,
[this](const QString &message)
{
statusBar()->showMessage(message, 5000);
});
}

void MainWindow::configurePropertyEditor()
{
control_id_edit_ = new QLineEdit(ui_->propertiesPage);
@@ -337,18 +483,75 @@ void MainWindow::configurePropertyEditor()
binding_index_spin_box_->setRange(0, RegisterAddress::kMaximumIndex);
apply_properties_button_ = new QPushButton(tr("应用属性"), ui_->propertiesPage);
apply_properties_button_->setObjectName(QStringLiteral("applyPropertiesButton"));
QFormLayout *form = ui_->propertiesForm;
form->addRow(tr("控件 ID"), control_id_edit_);
form->addRow(tr("显示文本"), control_text_edit_);
form->addRow(tr("X"), control_x_spin_box_);
form->addRow(tr("Y"), control_y_spin_box_);
form->addRow(tr("宽度"), control_width_spin_box_);
form->addRow(tr("高度"), control_height_spin_box_);
form->addRow(tr("绑定区域"), binding_area_combo_box_);
form->addRow(tr("绑定地址"), binding_index_spin_box_);
form->addRow(apply_properties_button_);
property_stack_ = new QStackedWidget(ui_->propertiesPage);
property_stack_->setObjectName(QStringLiteral("propertyStack"));
hmi_properties_page_ = new QWidget(property_stack_);
auto *hmi_form = new QFormLayout(hmi_properties_page_);
hmi_form->setContentsMargins(0, 0, 0, 0);
hmi_form->addRow(tr("控件 ID"), control_id_edit_);
hmi_form->addRow(tr("显示文本"), control_text_edit_);
hmi_form->addRow(tr("X"), control_x_spin_box_);
hmi_form->addRow(tr("Y"), control_y_spin_box_);
hmi_form->addRow(tr("宽度"), control_width_spin_box_);
hmi_form->addRow(tr("高度"), control_height_spin_box_);
hmi_form->addRow(tr("绑定区域"), binding_area_combo_box_);
hmi_form->addRow(tr("绑定地址"), binding_index_spin_box_);
hmi_form->addRow(apply_properties_button_);

logic_properties_page_ = new QWidget(property_stack_);
auto *logic_form = new QFormLayout(logic_properties_page_);
logic_form->setContentsMargins(0, 0, 0, 0);
logic_node_id_edit_ = new QLineEdit(logic_properties_page_);
logic_node_id_edit_->setObjectName(QStringLiteral("logicNodeIdEdit"));
logic_node_id_edit_->setReadOnly(true);
logic_node_type_label_ = new QLabel(logic_properties_page_);
logic_node_type_label_->setObjectName(QStringLiteral("logicNodeTypeLabel"));
logic_address_area_label_ = new QLabel(logic_properties_page_);
logic_address_area_label_->setObjectName(QStringLiteral("logicAddressAreaLabel"));
logic_address_spin_box_ = new QSpinBox(logic_properties_page_);
logic_address_spin_box_->setObjectName(QStringLiteral("logicAddressSpinBox"));
logic_address_spin_box_->setRange(0, RegisterAddress::kMaximumIndex);
logic_mode_combo_box_ = new QComboBox(logic_properties_page_);
logic_mode_combo_box_->setObjectName(QStringLiteral("logicModeComboBox"));
logic_comparison_combo_box_ = new QComboBox(logic_properties_page_);
logic_comparison_combo_box_->setObjectName(QStringLiteral("logicComparisonComboBox"));
logic_comparison_combo_box_->addItem(
tr("等于"), static_cast<int>(ComparisonOperator::Equal));
logic_comparison_combo_box_->addItem(
tr("不等于"), static_cast<int>(ComparisonOperator::NotEqual));
logic_comparison_combo_box_->addItem(
tr("小于"), static_cast<int>(ComparisonOperator::LessThan));
logic_comparison_combo_box_->addItem(
tr("小于等于"), static_cast<int>(ComparisonOperator::LessThanOrEqual));
logic_comparison_combo_box_->addItem(
tr("大于"), static_cast<int>(ComparisonOperator::GreaterThan));
logic_comparison_combo_box_->addItem(
tr("大于等于"), static_cast<int>(ComparisonOperator::GreaterThanOrEqual));
logic_value_spin_box_ = new QSpinBox(logic_properties_page_);
logic_value_spin_box_->setObjectName(QStringLiteral("logicValueSpinBox"));
logic_value_spin_box_->setRange(
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max());
apply_logic_properties_button_ = new QPushButton(
tr("应用属性"), logic_properties_page_);
apply_logic_properties_button_->setObjectName(
QStringLiteral("applyLogicPropertiesButton"));
logic_form->addRow(tr("节点 ID"), logic_node_id_edit_);
logic_form->addRow(tr("节点类型"), logic_node_type_label_);
logic_form->addRow(tr("地址区域"), logic_address_area_label_);
logic_form->addRow(tr("地址"), logic_address_spin_box_);
logic_form->addRow(tr("工作方式"), logic_mode_combo_box_);
logic_form->addRow(tr("比较运算"), logic_comparison_combo_box_);
logic_form->addRow(tr("比较常量"), logic_value_spin_box_);
logic_form->addRow(apply_logic_properties_button_);

property_stack_->addWidget(hmi_properties_page_);
property_stack_->addWidget(logic_properties_page_);
ui_->propertiesForm->addRow(property_stack_);
connect(apply_properties_button_, &QPushButton::clicked,
this, &MainWindow::applySelectedControlProperties);
connect(apply_logic_properties_button_, &QPushButton::clicked,
this, &MainWindow::applySelectedLogicNodeProperties);
showControlProperties({});
}

@@ -361,6 +564,11 @@ void MainWindow::refreshProjectUi()
hmi_editor_widget_->setPageId(current_page_id);
}
const HmiPage *page = hmi_editor_service_.findPage(current_page_id);
const std::string current_logic_id = logic_editor_service_.firstLogicId();
if (logic_editor_widget_ != nullptr)
{
logic_editor_widget_->setLogicId(current_logic_id);
}
ui_->projectTree->clear();
auto *hmi_root = new QTreeWidgetItem(ui_->projectTree, {tr("HMI 页面")});
if (page != nullptr)
@@ -375,13 +583,20 @@ void MainWindow::refreshProjectUi()
ui_->hmiPageTitleLabel->setText(tr("尚未创建 HMI 页面"));
ui_->hmiPageSizeLabel->clear();
}
new QTreeWidgetItem(ui_->projectTree, {tr("控制逻辑")});
auto *logic_root = new QTreeWidgetItem(ui_->projectTree, {tr("控制逻辑")});
for (const ControlLogic &logic : project_service_.project().controlLogics)
{
const QString suffix = logic.enabled ? QString{} : tr("(已禁用)");
logic_root->addChild(new QTreeWidgetItem(
logic_root, {fromUtf8(logic.name) + suffix}));
}
ui_->projectTree->expandAll();
}

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

@@ -439,6 +654,86 @@ void MainWindow::showControlProperties(const std::string &control_id)
control->binding.has_value() ? control->binding->index() : 0);
}

void MainWindow::showLogicNodeProperties(const std::string &node_id)
{
property_stack_->setCurrentWidget(logic_properties_page_);
selected_logic_node_id_ = node_id;
const LogicNode *node = logic_editor_service_.findNode(
logic_editor_service_.firstLogicId(), node_id);
const bool has_node = node != nullptr;
ui_->selectionValueLabel->setText(
has_node ? fromUtf8(node->id) : tr("未选择"));
for (QWidget *widget : {static_cast<QWidget *>(logic_node_id_edit_),
static_cast<QWidget *>(logic_address_spin_box_),
static_cast<QWidget *>(logic_mode_combo_box_),
static_cast<QWidget *>(logic_comparison_combo_box_),
static_cast<QWidget *>(logic_value_spin_box_),
static_cast<QWidget *>(apply_logic_properties_button_)})
{
widget->setEnabled(has_node);
}
logic_node_type_label_->setEnabled(has_node);
logic_address_area_label_->setEnabled(has_node);
if (!has_node)
{
logic_node_id_edit_->clear();
logic_node_type_label_->clear();
logic_address_area_label_->clear();
logic_mode_combo_box_->clear();
return;
}

logic_node_id_edit_->setText(fromUtf8(node->id));
logic_mode_combo_box_->clear();
if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config))
{
logic_node_type_label_->setText(tr("触点"));
logic_address_area_label_->setText(QStringLiteral("M"));
logic_address_spin_box_->setValue(contact->address.index());
logic_mode_combo_box_->addItem(
tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
logic_mode_combo_box_->addItem(
tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
logic_mode_combo_box_->setCurrentIndex(
logic_mode_combo_box_->findData(static_cast<int>(contact->mode)));
logic_mode_combo_box_->setEnabled(true);
logic_comparison_combo_box_->setEnabled(false);
logic_value_spin_box_->setEnabled(false);
}
else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config))
{
logic_node_type_label_->setText(tr("线圈"));
logic_address_area_label_->setText(QStringLiteral("M"));
logic_address_spin_box_->setValue(coil->address.index());
logic_mode_combo_box_->addItem(
tr("普通"), static_cast<int>(CoilMode::Normal));
logic_mode_combo_box_->addItem(
tr("置位"), static_cast<int>(CoilMode::Set));
logic_mode_combo_box_->addItem(
tr("复位"), static_cast<int>(CoilMode::Reset));
logic_mode_combo_box_->setCurrentIndex(
logic_mode_combo_box_->findData(static_cast<int>(coil->mode)));
logic_mode_combo_box_->setEnabled(true);
logic_comparison_combo_box_->setEnabled(false);
logic_value_spin_box_->setEnabled(false);
}
else
{
const auto &compare = std::get<CompareNodeConfig>(node->config);
logic_node_type_label_->setText(tr("D 值比较"));
logic_address_area_label_->setText(QStringLiteral("D"));
logic_address_spin_box_->setValue(compare.address.index());
logic_mode_combo_box_->addItem(tr("不适用"));
logic_mode_combo_box_->setEnabled(false);
logic_comparison_combo_box_->setEnabled(true);
logic_comparison_combo_box_->setCurrentIndex(
logic_comparison_combo_box_->findData(
static_cast<int>(compare.comparison)));
logic_value_spin_box_->setEnabled(true);
logic_value_spin_box_->setValue(compare.value);
}
}

void MainWindow::addHmiControl(HmiControlType type)
{
const std::string page_id = hmi_editor_service_.firstPageId();
@@ -507,6 +802,78 @@ void MainWindow::applySelectedControlProperties()
statusBar()->showMessage(tr("控件属性已更新"), 3000);
}

void MainWindow::addLogicNode(const LogicNodeConfig &config)
{
const LogicEditorResult result = logic_editor_widget_->addNode(config);
if (!result.succeeded)
{
showProjectResult(tr("添加逻辑节点"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_ = result.id;
showLogicNodeProperties(result.id);
refreshProjectUi();
statusBar()->showMessage(tr("已添加逻辑节点"), 3000);
}

void MainWindow::deleteSelectedLogicObject()
{
const LogicEditorResult result = logic_editor_widget_->deleteSelected();
if (!result.succeeded)
{
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
}

void MainWindow::applySelectedLogicNodeProperties()
{
const std::string logic_id = logic_editor_service_.firstLogicId();
const LogicNode *node = logic_editor_service_.findNode(
logic_id, selected_logic_node_id_);
if (node == nullptr)
{
return;
}

LogicNodeConfig config = node->config;
if (std::holds_alternative<ContactNodeConfig>(config))
{
config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, logic_address_spin_box_->value()},
static_cast<ContactMode>(logic_mode_combo_box_->currentData().toInt())};
}
else if (std::holds_alternative<CoilNodeConfig>(config))
{
config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, logic_address_spin_box_->value()},
static_cast<CoilMode>(logic_mode_combo_box_->currentData().toInt())};
}
else
{
config = CompareNodeConfig{
RegisterAddress{RegisterArea::D, logic_address_spin_box_->value()},
static_cast<ComparisonOperator>(
logic_comparison_combo_box_->currentData().toInt()),
static_cast<std::int16_t>(logic_value_spin_box_->value())};
}

const LogicEditorResult result = logic_editor_service_.updateNodeConfig(
logic_id, selected_logic_node_id_, config);
if (!result.succeeded)
{
showProjectResult(tr("应用逻辑属性"), fromUtf8(result.message), false);
return;
}
logic_editor_widget_->reloadLogic();
logic_editor_widget_->selectNode(selected_logic_node_id_);
showLogicNodeProperties(selected_logic_node_id_);
refreshProjectUi();
statusBar()->showMessage(tr("逻辑节点属性已更新"), 3000);
}

void MainWindow::createNewProject()
{
const QString name = QInputDialog::getText(
@@ -522,9 +889,12 @@ void MainWindow::createNewProject()
return;
}
hmi_editor_service_.ensureDefaultPage();
logic_editor_service_.ensureDefaultLogic();
selected_control_id_.clear();
selected_logic_node_id_.clear();
refreshProjectUi();
hmi_editor_widget_->reloadPage();
logic_editor_widget_->reloadLogic();
showControlProperties({});
statusBar()->showMessage(tr("已创建新工程"), 3000);
}
@@ -570,8 +940,10 @@ void MainWindow::loadProject()
return;
}
selected_control_id_.clear();
selected_logic_node_id_.clear();
refreshProjectUi();
hmi_editor_widget_->reloadPage();
logic_editor_widget_->reloadLogic();
showControlProperties({});
showProjectResult(tr("加载工程"), tr("工程已加载"), true);
}
@@ -636,12 +1008,21 @@ void MainWindow::updateModeUi(const QString &message)
ui_->propertiesDock->setEnabled(policy.allowsProjectEditing);
hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing);
hmi_editor_widget_->setRuntimeActive(policy.usesVirtualRegisters);
logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing);
hmi_tool_bar_->setEnabled(policy.allowsProjectEditing);
logic_tool_bar_->setEnabled(policy.allowsProjectEditing);
add_button_action_->setEnabled(policy.allowsProjectEditing);
add_indicator_action_->setEnabled(policy.allowsProjectEditing);
add_numeric_display_action_->setEnabled(policy.allowsProjectEditing);
add_numeric_input_action_->setEnabled(policy.allowsProjectEditing);
delete_control_action_->setEnabled(policy.allowsProjectEditing);
add_normally_open_action_->setEnabled(policy.allowsProjectEditing);
add_normally_closed_action_->setEnabled(policy.allowsProjectEditing);
add_normal_coil_action_->setEnabled(policy.allowsProjectEditing);
add_set_coil_action_->setEnabled(policy.allowsProjectEditing);
add_reset_coil_action_->setEnabled(policy.allowsProjectEditing);
add_compare_action_->setEnabled(policy.allowsProjectEditing);
delete_logic_action_->setEnabled(policy.allowsProjectEditing);
new_project_action_->setEnabled(policy.allowsProjectEditing);
save_project_action_->setEnabled(policy.allowsProjectEditing);
save_as_project_action_->setEnabled(policy.allowsProjectEditing);


+ 37
- 0
app/src/ui/main_window.h Parādīt failu

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

#include "domain/hmi_model.h"
#include "domain/control_logic_model.h"
#include "domain/runtime_state.h"

#include <QMainWindow>
@@ -23,6 +24,7 @@ class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QStackedWidget;
class QTimer;
class QToolBar;

@@ -36,6 +38,8 @@ class ProjectService;
class HmiEditorService;
class HmiRuntimeService;
class HmiEditorWidget;
class LogicEditorService;
class LogicEditorWidget;

/**
* @brief 组织主界面区域并将用户操作转交给服务层
@@ -59,6 +63,7 @@ public:
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
HmiRuntimeService &hmi_runtime_service,
QWidget *parent = nullptr);

@@ -71,18 +76,28 @@ private:
void configureAppearance();
// 创建并连接 HMI 编辑画布
void configureHmiEditor();
// 创建并连接控制逻辑编辑画布
void configureLogicEditor();
// 创建并连接控件属性编辑表单
void configurePropertyEditor();
// 刷新工程树和当前 HMI 页面信息
void refreshProjectUi();
// 显示指定 HMI 控件的可编辑属性
void showControlProperties(const std::string &control_id);
// 显示指定控制逻辑节点的可编辑属性
void showLogicNodeProperties(const std::string &node_id);
// 向当前页面添加指定类型的 HMI 控件
void addHmiControl(HmiControlType type);
// 删除当前选中的 HMI 控件
void deleteSelectedControl();
// 将属性表单内容应用到当前选中控件
void applySelectedControlProperties();
// 向当前控制逻辑添加指定配置的节点
void addLogicNode(const LogicNodeConfig &config);
// 删除当前选中的逻辑节点或连接
void deleteSelectedLogicObject();
// 将逻辑属性表单内容应用到当前选中节点
void applySelectedLogicNodeProperties();
// 创建新的工程并刷新编辑界面
void createNewProject();
// 保存当前工程到已关联的路径
@@ -123,6 +138,7 @@ private:
*/
ProjectService &project_service_;
HmiEditorService &hmi_editor_service_;
LogicEditorService &logic_editor_service_;
HmiRuntimeService &hmi_runtime_service_;
QActionGroup *mode_action_group_ = nullptr;
QAction *new_project_action_ = nullptr;
@@ -134,8 +150,17 @@ private:
QAction *add_numeric_display_action_ = nullptr;
QAction *add_numeric_input_action_ = nullptr;
QAction *delete_control_action_ = nullptr;
QAction *add_normally_open_action_ = nullptr;
QAction *add_normally_closed_action_ = nullptr;
QAction *add_normal_coil_action_ = nullptr;
QAction *add_set_coil_action_ = nullptr;
QAction *add_reset_coil_action_ = nullptr;
QAction *add_compare_action_ = nullptr;
QAction *delete_logic_action_ = nullptr;
QToolBar *hmi_tool_bar_ = nullptr;
QToolBar *logic_tool_bar_ = nullptr;
HmiEditorWidget *hmi_editor_widget_ = nullptr;
LogicEditorWidget *logic_editor_widget_ = nullptr;
QTimer *runtime_refresh_timer_ = nullptr;
QLabel *mode_status_label_ = nullptr;
QLabel *register_status_label_ = nullptr;
@@ -149,5 +174,17 @@ private:
QComboBox *binding_area_combo_box_ = nullptr;
QSpinBox *binding_index_spin_box_ = nullptr;
QPushButton *apply_properties_button_ = nullptr;
QStackedWidget *property_stack_ = nullptr;
QWidget *hmi_properties_page_ = nullptr;
QWidget *logic_properties_page_ = nullptr;
QLineEdit *logic_node_id_edit_ = nullptr;
QLabel *logic_node_type_label_ = nullptr;
QLabel *logic_address_area_label_ = nullptr;
QSpinBox *logic_address_spin_box_ = nullptr;
QComboBox *logic_mode_combo_box_ = nullptr;
QComboBox *logic_comparison_combo_box_ = nullptr;
QSpinBox *logic_value_spin_box_ = nullptr;
QPushButton *apply_logic_properties_button_ = nullptr;
std::string selected_control_id_;
std::string selected_logic_node_id_;
};

+ 49
- 0
app/tests/domain_tests.cpp Parādīt failu

@@ -117,6 +117,54 @@ void testLogicNodeConfigurationBoundaries()
require(comparison.validate(), "comparison node bound to D address must be valid");
}

void testLogicGraphBoundaries()
{
LogicNode start;
start.id = "start";
start.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};

LogicNode run_contact;
run_contact.id = "run-contact";
run_contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyOpen};

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

ControlLogic logic;
logic.id = "hold-logic";
logic.name = "Hold logic";
logic.nodes = {start, run_contact, coil};
logic.connections = {
{start.id, coil.id},
{run_contact.id, coil.id}};
require(logic.validate(), "a branched self-hold graph must be valid");

logic.connections.push_back({start.id, coil.id});
require(!logic.validate(), "duplicate logic connections must be rejected");

logic.connections.pop_back();
logic.connections.push_back({coil.id, start.id});
require(!logic.validate(), "coil outgoing connections must be rejected");

logic.connections.pop_back();
logic.connections.push_back({start.id, run_contact.id});
logic.connections.push_back({run_contact.id, start.id});
require(!logic.validate(), "logic connection cycles must be rejected");

logic.connections.pop_back();
logic.connections.pop_back();
logic.connections.clear();
logic.connections.push_back({start.id, coil.id});
require(!logic.validate(), "a disconnected condition node must be rejected");
}

void testModelsValidateBindingsAndIdentifiers()
{
// 聚合验证必须拒绝错误绑定、重复标识和越界控件
@@ -177,6 +225,7 @@ int main()
testRegisterAddressBoundaries();
testRegisterRepositorySeparatesAreas();
testLogicNodeConfigurationBoundaries();
testLogicGraphBoundaries();
testModelsValidateBindingsAndIdentifiers();
testRuntimeStateBoundaries();
}


+ 102
- 0
app/tests/logic_editor_service_tests.cpp Parādīt failu

@@ -0,0 +1,102 @@
#include "domain/project_storage.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"

#include <exception>
#include <iostream>
#include <stdexcept>

namespace {

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

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

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

void testEditorOperations()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const LogicEditorResult logic_result = service.ensureDefaultLogic();
require(logic_result.succeeded, "default logic must be created");
const std::string logic_id = logic_result.id;

const LogicEditorResult start_result = service.addNode(
logic_id,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen},
{80, 80});
const LogicEditorResult coil_result = service.addNode(
logic_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1},
CoilMode::Normal},
{360, 80});
require(start_result.succeeded && coil_result.succeeded,
"contact and coil must be added");

require(service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded,
"valid nodes must connect");
require(!service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded,
"duplicate node connections must fail");
require(!service.connectNodes(logic_id, coil_result.id, start_result.id).succeeded,
"coil outgoing connection must fail");

const LogicNode *start = service.findNode(logic_id, start_result.id);
require(start != nullptr && start->position.x == 80,
"added node position must be stored");
require(service.moveNode(logic_id, start_result.id, {120, 160}).succeeded,
"node position must be editable");
require(service.updateNodeConfig(
logic_id,
start_result.id,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 2},
ContactMode::NormallyClosed})
.succeeded,
"contact properties must be editable");

require(service.removeNode(logic_id, start_result.id).succeeded,
"node deletion must succeed");
require(service.findNode(logic_id, start_result.id) == nullptr,
"deleted node must disappear");
require(service.findLogic(logic_id)->connections.empty(),
"deleting a node must remove its connections");
}

} // namespace

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

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

+ 27
- 0
app/tests/logic_editor_service_tests.pro Parādīt failu

@@ -0,0 +1,27 @@
TEMPLATE = app
TARGET = logic_editor_service_tests

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle qt

INCLUDEPATH += ../src

SOURCES += \
logic_editor_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/register_repository.cpp \
../src/services/project_service.cpp \
../src/services/logic_editor_service.cpp

HEADERS += \
../src/domain/register_address.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/hmi_model.h \
../src/domain/register_repository.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/logic_editor_service.h

+ 23
- 1
app/tests/main_window_tests.cpp Parādīt failu

@@ -2,9 +2,11 @@
#include "domain/register_repository.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui/hmi_editor_widget.h"
#include "ui/logic_editor_widget.h"
#include "ui/main_window.h"

#include <QAction>
@@ -57,11 +59,16 @@ void testModeActionsControlEditingAvailability()
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService editor_service(project_service);
LogicEditorService logic_editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
RuntimeModeService mode_service;
MainWindow window(
mode_service, project_service, editor_service, runtime_service);
mode_service,
project_service,
editor_service,
logic_editor_service,
runtime_service);
window.resize(1000, 640);
window.show();
QApplication::processEvents();
@@ -71,6 +78,10 @@ void testModeActionsControlEditingAvailability()
QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
QAction *add_normally_open_action = requiredChild<QAction>(
window, "addNormallyOpenAction");
QAction *add_normal_coil_action = requiredChild<QAction>(
window, "addNormalCoilAction");
QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
@@ -100,6 +111,13 @@ void testModeActionsControlEditingAvailability()
require(editor_service.findPage(page_id)->controls.empty(),
"deleting a selected control must update the HMI page model");

add_normally_open_action->trigger();
add_normal_coil_action->trigger();
const ControlLogic *logic = logic_editor_service.findLogic(
logic_editor_service.firstLogicId());
require(logic != nullptr && logic->nodes.size() == 2,
"logic actions must add nodes through the logic editor service");

offline_action->trigger();
require(mode_service.mode() == ApplicationMode::OfflineRunning,
"offline action must enter offline running");
@@ -109,6 +127,8 @@ void testModeActionsControlEditingAvailability()
"properties dock must be disabled while running");
require(!add_button_action->isEnabled(),
"HMI add controls must be disabled while running");
require(!add_normally_open_action->isEnabled(),
"logic add nodes must be disabled while running");

online_action->trigger();
require(mode_service.mode() == ApplicationMode::OfflineRunning,
@@ -125,6 +145,8 @@ void testModeActionsControlEditingAvailability()
"properties dock must be restored after returning to editing");
require(add_button_action->isEnabled(),
"HMI add controls must be restored after returning to editing");
require(add_normally_open_action->isEnabled(),
"logic add nodes must be restored after returning to editing");

online_action->trigger();
require(mode_service.mode() == ApplicationMode::Editing,


+ 4
- 0
app/tests/main_window_tests.pro Parādīt failu

@@ -12,6 +12,7 @@ SOURCES += \
main_window_tests.cpp \
../src/ui/main_window.cpp \
../src/ui/hmi_editor_widget.cpp \
../src/ui/logic_editor_widget.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
@@ -20,12 +21,14 @@ SOURCES += \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/hmi_editor_service.cpp \
../src/services/logic_editor_service.cpp \
../src/services/hmi_runtime_service.cpp \
../src/services/runtime_mode_service.cpp

HEADERS += \
../src/ui/main_window.h \
../src/ui/hmi_editor_widget.h \
../src/ui/logic_editor_widget.h \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
@@ -35,6 +38,7 @@ HEADERS += \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/hmi_editor_service.h \
../src/services/logic_editor_service.h \
../src/services/hmi_runtime_service.h \
../src/services/runtime_mode_service.h



+ 5
- 0
app/tests/project_management_tests.cpp Parādīt failu

@@ -68,6 +68,7 @@ Project makeExampleProject()
contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};
contact.position = {80, 100};

LogicNode compare;
compare.id = "temperature-check";
@@ -75,12 +76,14 @@ Project makeExampleProject()
RegisterAddress{RegisterArea::D, 2},
ComparisonOperator::GreaterThanOrEqual,
static_cast<std::int16_t>(100)};
compare.position = {320, 100};

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

ControlLogic logic;
logic.id = "start-logic";
@@ -176,6 +179,8 @@ void testExampleProjectRoundTrip()
"control logic enabled state must survive round trip");
require(project.controlLogics.front().nodes.size() == 3,
"logic node count must survive round trip");
require(project.controlLogics.front().nodes.front().position.x == 80,
"logic node position must survive round trip");

const auto &compare = std::get<CompareNodeConfig>(
project.controlLogics.front().nodes.at(1).config);


+ 18
- 3
docs/ai/handoff.md Parādīt failu

@@ -2,7 +2,7 @@

- Goal: 完成综合平台编程器的工程管理和后续编辑功能。
- Branch: `main`。
- Current status: 已完成开发顺序第 6 步,HMI 编辑器、工程保存加载闭环和离线 HMI 寄存器适配已完成。
- Current status: 已完成开发顺序第 7 步,控制逻辑图形编辑、领域校验和工程持久化闭环已完成。
- Changed files:
- `app/integrated_platform.pro`
- `app/src/main.cpp`
@@ -52,6 +52,12 @@
- `app/tests/main_window_tests.cpp`
- `app/src/ui/hmi_editor_widget.h`
- `app/src/ui/hmi_editor_widget.cpp`
- `app/src/services/logic_editor_service.h`
- `app/src/services/logic_editor_service.cpp`
- `app/src/ui/logic_editor_widget.h`
- `app/src/ui/logic_editor_widget.cpp`
- `app/tests/logic_editor_service_tests.pro`
- `app/tests/logic_editor_service_tests.cpp`
- Decisions made:
- Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。
- 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。
@@ -80,6 +86,12 @@
- 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。
- 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。
- `HmiRuntimeService` 仅通过 `RegisterRepository` 访问寄存器,不依赖虚拟寄存器、串口或 Modbus。
- 控制逻辑使用有向无环图;串联为 AND、多个前驱汇合为 OR、一个输出连接多个后继为并行分支。
- 线圈只能作为终点,保存时要求线圈有输入且每个条件节点最终到达线圈。
- 状态保持通过触点与线圈共享 M 地址表达,不创建图上的反馈环。
- `LogicEditorService` 负责节点和连接编辑,删除节点会同时删除关联连接。
- `LogicEditorWidget` 只保存节点 ID 和连接端点,不访问 HMI、寄存器或 PLC。
- 节点位置保存在 `1.0` 工程文件中,并作为逻辑节点的必填工程字段。
- Validation run and results:
- 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。
- 已启动 `integrated_platform.exe` 并正常退出。
@@ -100,6 +112,9 @@
- 在 `build/hmi-editor/` 重新构建 Release 应用,构建成功并完成启动冒烟检查。
- 在 `build/hmi-editor-service-tests/` 构建并运行 HMI 服务测试,输出 `HMI editor service tests passed`。
- 重新构建并运行领域、工程管理和主窗口测试,全部通过。
- Remaining work: 进入开发顺序第 7 步,实现控制逻辑编辑器。
- 控制逻辑编辑器完成后,领域、逻辑编辑服务、工程管理和主窗口测试全部通过。
- Debug 应用构建成功。
- 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。
- Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。
- Known risks / blockers: 无。
- Suggested next command: 为控制逻辑建立独立图形编辑器和服务层编辑用例。
- Suggested next command: 为控制逻辑建立固定周期的软件执行器并按拓扑顺序求值

+ 10
- 0
docs/architecture.md Parādīt failu

@@ -84,6 +84,16 @@ main.cpp -> UI + Services + Infrastructure
常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续
添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。

控制逻辑使用有向无环图表达。没有前驱的条件节点从隐式左母线取得真值,条件节点输出为
前驱输入与自身条件的逻辑与;多个前驱在汇合处执行逻辑或,一个输出连接多个后继表示并行
分支。线圈只能作为终点。状态保持通过触点和线圈绑定同一个 M 地址表达,不建立从线圈返回
触点的环形连接。上述语义是后续软件逻辑执行器的确定性求值契约。

`services/LogicEditorService` 负责逻辑节点的添加、移动、属性更新、连接、断开和删除。编辑服务
只要求每次操作后的结构合法,允许用户暂时保留尚未连接完成的中间状态;工程保存仍调用完整
领域校验,拒绝无输入线圈和不能到达线圈的条件节点。`ui/LogicEditorWidget` 只投影节点标识和
连接端点,通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。节点画布位置随工程保存。

## 运行模式边界

| 模式 | 允许编辑工程 | 寄存器来源 | 软件逻辑执行器 | 进入约束 |


Notiek ielāde…
Atcelt
Saglabāt