浏览代码

feat: 添加应用外部配置

main
suyu 3 周前
父节点
当前提交
6594ef1183
共有 33 个文件被更改,包括 1272 次插入65 次删除
  1. +3
    -0
      app/integrated_platform.pro
  2. +26
    -4
      app/src/domain/control_logic_model.cpp
  3. +9
    -0
      app/src/domain/control_logic_model.h
  4. +19
    -3
      app/src/domain/hmi_model.cpp
  5. +6
    -0
      app/src/domain/hmi_model.h
  6. +32
    -0
      app/src/domain/project_limits.h
  7. +36
    -11
      app/src/domain/project_model.cpp
  8. +7
    -0
      app/src/domain/project_model.h
  9. +509
    -0
      app/src/infrastructure/application_settings_loader.cpp
  10. +13
    -0
      app/src/infrastructure/application_settings_loader.h
  11. +25
    -10
      app/src/infrastructure/json_project_storage.cpp
  12. +6
    -0
      app/src/infrastructure/json_project_storage.h
  13. +12
    -4
      app/src/main.cpp
  14. +6
    -2
      app/src/services/alarm_editor_service.cpp
  15. +30
    -0
      app/src/services/application_settings.h
  16. +29
    -10
      app/src/services/hmi_editor_service.cpp
  17. +4
    -1
      app/src/services/hmi_editor_service.h
  18. +29
    -12
      app/src/services/logic_editor_service.cpp
  19. +15
    -2
      app/src/services/project_service.cpp
  20. +5
    -0
      app/src/services/project_service.h
  21. +2
    -1
      app/src/services/runtime_mode_service.cpp
  22. +29
    -3
      app/src/ui/main_window.cpp
  23. +5
    -0
      app/src/ui/main_window.h
  24. +4
    -2
      app/src/ui/property_panel_controller.cpp
  25. +3
    -0
      app/src/ui/property_panel_controller.h
  26. +17
    -0
      app/tests/alarm_service_tests.cpp
  27. +252
    -0
      app/tests/application_settings_tests.cpp
  28. +17
    -0
      app/tests/application_settings_tests.pro
  29. +50
    -0
      app/tests/domain_tests.cpp
  30. +25
    -0
      app/tests/hmi_editor_service_tests.cpp
  31. +19
    -0
      app/tests/logic_editor_service_tests.cpp
  32. +26
    -0
      app/tests/project_management_tests.cpp
  33. +2
    -0
      app/tests/tests.pro

+ 3
- 0
app/integrated_platform.pro 查看文件

@@ -55,6 +55,7 @@ SOURCES += \
src/infrastructure/plc_communication_error_classifier.cpp \
src/infrastructure/plc_communication_service.cpp \
src/infrastructure/plc_discovery_service.cpp \
src/infrastructure/application_settings_loader.cpp \
src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp \
src/ui/logic_editor_widget.cpp
@@ -100,10 +101,12 @@ HEADERS += \
src/services/register_monitor_service.h \
src/services/register_comment_service.h \
src/services/plc_communication_gateway.h \
src/services/application_settings.h \
src/infrastructure/plc_register_repository.h \
src/infrastructure/plc_communication_error_classifier.h \
src/infrastructure/plc_communication_service.h \
src/infrastructure/plc_discovery_service.h \
src/infrastructure/application_settings_loader.h \
src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.h \
src/ui/logic_editor_widget.h


+ 26
- 4
app/src/domain/control_logic_model.cpp 查看文件

@@ -820,7 +820,13 @@ bool LadderRung::validateStructure(std::string *error) const

bool ControlLogic::validate(std::string *error) const
{
if (!validateStructure(error))
return validate(defaultProjectLimitSettings(), error);
}

bool ControlLogic::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
if (!validateStructure(limits, error))
{
return false;
}
@@ -835,6 +841,12 @@ bool ControlLogic::validate(std::string *error) const
}

bool ControlLogic::validateStructure(std::string *error) const
{
return validateStructure(defaultProjectLimitSettings(), error);
}

bool ControlLogic::validateStructure(
const ProjectLimitSettings &limits, std::string *error) const
{
if (id.empty() || name.empty())
{
@@ -851,9 +863,13 @@ bool ControlLogic::validateStructure(std::string *error) const
setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节");
return false;
}
if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic)
if (rungs.size() > limits.maximumRungsPerLogic)
{
setError(error, "单组控制逻辑最多包含 256 个网络");
setError(
error,
"控制逻辑“" + name + "”的网络数量为 "
+ std::to_string(rungs.size()) + ",当前配置上限为 "
+ std::to_string(limits.maximumRungsPerLogic));
return false;
}
if (hasDuplicateId(rungs))
@@ -902,7 +918,13 @@ bool ControlLogic::validateStructure(std::string *error) const

bool ControlLogic::validateForRunning(std::string *error) const
{
if (!validateStructure(error))
return validateForRunning(defaultProjectLimitSettings(), error);
}

bool ControlLogic::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
if (!validateStructure(limits, error))
{
return false;
}


+ 9
- 0
app/src/domain/control_logic_model.h 查看文件

@@ -257,7 +257,16 @@ struct ControlLogic

// 校验可保存结构
bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
bool validateStructure(std::string *error = nullptr) const;
bool validateStructure(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 校验运行所需资源和网络配置
bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
};

+ 19
- 3
app/src/domain/hmi_model.cpp 查看文件

@@ -220,6 +220,12 @@ bool HmiControl::isConfigured() const
}

bool HmiPage::validate(std::string *error) const
{
return validate(defaultProjectLimitSettings(), error);
}

bool HmiPage::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
if (id.empty() || name.empty())
{
@@ -246,9 +252,13 @@ bool HmiPage::validate(std::string *error) const
"HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内");
return false;
}
if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage)
if (controls.size() > limits.maximumHmiControlsPerPage)
{
setError(error, "单个 HMI 页面最多包含 128 个控件");
setError(
error,
"HMI 页面“" + name + "”的控件数量为 "
+ std::to_string(controls.size()) + ",当前配置上限为 "
+ std::to_string(limits.maximumHmiControlsPerPage));
return false;
}

@@ -280,7 +290,13 @@ bool HmiPage::validate(std::string *error) const

bool HmiPage::validateForRunning(std::string *error) const
{
if (!validate(error))
return validateForRunning(defaultProjectLimitSettings(), error);
}

bool HmiPage::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
if (!validate(limits, error))
{
return false;
}


+ 6
- 0
app/src/domain/hmi_model.h 查看文件

@@ -120,5 +120,11 @@ struct HmiPage
* @return 页面及其全部控件有效时返回 true
*/
bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
};

+ 32
- 0
app/src/domain/project_limits.h 查看文件

@@ -66,3 +66,35 @@ constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个
constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条

}

// 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限
struct ProjectLimitSettings
{
std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages;
std::size_t maximumHmiControlsPerPage =
ProjectLimits::kMaximumHmiControlsPerPage;
std::size_t maximumAlarmDefinitions =
ProjectLimits::kMaximumAlarmDefinitions;
std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics;
std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic;
int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages;
};

// 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制
struct HmiDefaultSettings
{
int pageWidth = ProjectLimits::kDefaultHmiPageWidth;
int pageHeight = ProjectLimits::kDefaultHmiPageHeight;
};

inline const ProjectLimitSettings &defaultProjectLimitSettings()
{
static const ProjectLimitSettings settings;
return settings;
}

inline const HmiDefaultSettings &defaultHmiSettings()
{
static const HmiDefaultSettings settings;
return settings;
}

+ 36
- 11
app/src/domain/project_model.cpp 查看文件

@@ -110,6 +110,12 @@ const RegisterComment *Project::findRegisterComment(
}

bool Project::validate(std::string *error) const
{
return validate(defaultProjectLimitSettings(), error);
}

bool Project::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
// 这里是工程级总闸门:先检查数量和唯一性,再向下委托页面、报警和逻辑
if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty())
@@ -133,14 +139,23 @@ bool Project::validate(std::string *error) const
setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (hmiPages.size() > ProjectLimits::kMaximumHmiPages)
if (hmiPages.size() > limits.maximumHmiPages)
{
setError(error, "单个工程最多包含 32 个 HMI 页面");
setError(
error,
"工程 HMI 页面数量为 " + std::to_string(hmiPages.size())
+ ",当前配置上限为 "
+ std::to_string(limits.maximumHmiPages));
return false;
}
if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions)
if (alarmDefinitions.size() > limits.maximumAlarmDefinitions)
{
setError(error, "单个工程最多包含 256 条报警定义");
setError(
error,
"工程报警定义数量为 "
+ std::to_string(alarmDefinitions.size())
+ ",当前配置上限为 "
+ std::to_string(limits.maximumAlarmDefinitions));
return false;
}
if (registerComments.size() > ProjectLimits::kMaximumRegisterComments)
@@ -148,9 +163,13 @@ bool Project::validate(std::string *error) const
setError(error, "单个工程最多包含 8002 条软元件注释");
return false;
}
if (controlLogics.size() > ProjectLimits::kMaximumControlLogics)
if (controlLogics.size() > limits.maximumControlLogics)
{
setError(error, "单个工程最多包含 32 组控制逻辑");
setError(
error,
"工程控制逻辑数量为 " + std::to_string(controlLogics.size())
+ ",当前配置上限为 "
+ std::to_string(limits.maximumControlLogics));
return false;
}
std::size_t total_hmi_controls = 0U;
@@ -246,7 +265,7 @@ bool Project::validate(std::string *error) const
for (const HmiPage &page : hmiPages)
{
// 工程聚合校验会向下委托页面和控件的完整规则
if (!page.validate(error))
if (!page.validate(limits, error))
{
return false;
}
@@ -273,7 +292,7 @@ bool Project::validate(std::string *error) const
}
for (const ControlLogic &logic : controlLogics)
{
if (!logic.validate(error))
if (!logic.validate(limits, error))
{
return false;
}
@@ -334,22 +353,28 @@ bool Project::validate(std::string *error) const
}

bool Project::validateForRunning(std::string *error) const
{
return validateForRunning(defaultProjectLimitSettings(), error);
}

bool Project::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
// 运行校验建立在可保存校验之上,再额外拒绝未配置的运行对象
if (!validate(error))
if (!validate(limits, error))
{
return false;
}
for (const HmiPage &page : hmiPages)
{
if (!page.validateForRunning(error))
if (!page.validateForRunning(limits, error))
{
return false;
}
}
for (const ControlLogic &logic : controlLogics)
{
if (logic.enabled && !logic.validateForRunning(error))
if (logic.enabled && !logic.validateForRunning(limits, error))
{
return false;
}


+ 7
- 0
app/src/domain/project_model.h 查看文件

@@ -3,6 +3,7 @@
#include "alarm_model.h"
#include "control_logic_model.h"
#include "hmi_model.h"
#include "project_limits.h"

#include <string>
#include <vector>
@@ -48,6 +49,12 @@ struct Project
const RegisterComment *findRegisterComment(const RegisterAddress &address) const;
// 编辑态校验:允许尚未绑定完成的草稿
bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 运行态校验:额外要求所有运行所需对象都已配置
bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
};

+ 509
- 0
app/src/infrastructure/application_settings_loader.cpp 查看文件

@@ -0,0 +1,509 @@
#include "application_settings_loader.h"

#include "domain/project_limits.h"

#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMap>
#include <QSaveFile>
#include <QSet>
#include <QStringList>
#include <QTextCodec>

#include <initializer_list>

namespace {

constexpr int kCurrentConfigurationVersion = 1;

struct ParsedEntry
{
QString value;
int line = 0;
};

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

QString defaultFileContents()
{
return QStringLiteral(
"[Config]\n"
"Version=1\n"
"\n"
"[ProjectLimits]\n"
"MaxHmiPages=32\n"
"MaxHmiControlsPerPage=128\n"
"MaxAlarmDefinitions=256\n"
"MaxControlLogics=32\n"
"MaxRungsPerLogic=256\n"
"MaxOutputMessages=1000\n"
"\n"
"[HmiDefaults]\n"
"DefaultPageWidth=800\n"
"DefaultPageHeight=400\n"
"\n"
"[PlcDefaults]\n"
"PortName=COM3\n"
"ServerAddress=1\n"
"BaudRate=9600\n"
"DataBits=8\n"
"Parity=2\n"
"StopBits=1\n");
}

ApplicationSettingsLoadResult failureResult(
const QString &file_path, const QString &reason)
{
ApplicationSettingsLoadResult result;
result.warningRequired = true;
result.warningMessage = toUtf8(
QStringLiteral("应用配置无法使用,本次已全部采用代码默认值。\n%1")
.arg(reason));
result.messages.push_back(toUtf8(
QStringLiteral("应用配置错误:%1(%2)")
.arg(reason, QDir::toNativeSeparators(file_path))));
result.messages.push_back(
"应用配置:本次运行全部使用代码默认值");
return result;
}

bool containsValue(std::initializer_list<int> values, int value)
{
for (int candidate : values)
{
if (candidate == value)
{
return true;
}
}
return false;
}

bool readInteger(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
qint64 minimum,
qint64 maximum,
qint64 default_value,
qint64 *value,
QStringList *notices,
QStringList *errors,
bool required = false)
{
const auto entry = entries.constFind(path);
if (entry == entries.cend())
{
if (required)
{
errors->append(QStringLiteral("缺少必填字段 %1").arg(path));
}
else
{
notices->append(
QStringLiteral("配置字段 %1 缺失,使用代码默认值 %2")
.arg(path)
.arg(default_value));
}
*value = default_value;
return !required;
}

bool converted = false;
const qint64 parsed = entry->value.toLongLong(&converted, 10);
if (!converted || parsed < minimum || parsed > maximum)
{
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,允许范围为 %4~%5")
.arg(entry->line)
.arg(path, entry->value)
.arg(minimum)
.arg(maximum));
return false;
}
*value = parsed;
return true;
}

void addUnsupportedValueError(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
const QString &allowed,
QStringList *errors)
{
const ParsedEntry &entry = entries[path];
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4")
.arg(entry.line)
.arg(path, entry.value, allowed));
}

} // namespace

QString ApplicationSettingsLoader::defaultFilePath()
{
return QDir(QCoreApplication::applicationDirPath()).filePath(
QStringLiteral("config/application.ini"));
}

ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
const QString &file_path)
{
if (!QFileInfo::exists(file_path))
{
const QString directory = QFileInfo(file_path).absolutePath();
if (!QDir().mkpath(directory))
{
return failureResult(
file_path,
QStringLiteral("无法创建配置目录 %1,可能没有写入权限")
.arg(QDir::toNativeSeparators(directory)));
}

QSaveFile file(file_path);
if (!file.open(QIODevice::WriteOnly))
{
return failureResult(file_path, file.errorString());
}
const QByteArray data = defaultFileContents().toUtf8();
if (file.write(data) != data.size())
{
const QString reason = file.errorString();
file.cancelWriting();
return failureResult(file_path, reason);
}
if (!file.commit())
{
return failureResult(file_path, file.errorString());
}

ApplicationSettingsLoadResult result;
result.messages.push_back(toUtf8(
QStringLiteral("应用配置:已创建默认配置文件 %1")
.arg(QDir::toNativeSeparators(file_path))));
result.messages.push_back(
"应用配置:首次运行使用代码默认值,修改后请重启程序");
return result;
}

QFile file(file_path);
if (!file.open(QIODevice::ReadOnly))
{
return failureResult(file_path, file.errorString());
}
const QByteArray data = file.readAll();
if (file.error() != QFileDevice::NoError)
{
return failureResult(file_path, file.errorString());
}

QTextCodec::ConverterState converter_state;
const QString text = QTextCodec::codecForName("UTF-8")->toUnicode(
data.constData(), data.size(), &converter_state);
if (converter_state.invalidChars > 0)
{
return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本"));
}

const QSet<QString> known_fields{
QStringLiteral("Config.Version"),
QStringLiteral("ProjectLimits.MaxHmiPages"),
QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"),
QStringLiteral("ProjectLimits.MaxAlarmDefinitions"),
QStringLiteral("ProjectLimits.MaxControlLogics"),
QStringLiteral("ProjectLimits.MaxRungsPerLogic"),
QStringLiteral("ProjectLimits.MaxOutputMessages"),
QStringLiteral("HmiDefaults.DefaultPageWidth"),
QStringLiteral("HmiDefaults.DefaultPageHeight"),
QStringLiteral("PlcDefaults.PortName"),
QStringLiteral("PlcDefaults.ServerAddress"),
QStringLiteral("PlcDefaults.BaudRate"),
QStringLiteral("PlcDefaults.DataBits"),
QStringLiteral("PlcDefaults.Parity"),
QStringLiteral("PlcDefaults.StopBits")};

QMap<QString, ParsedEntry> entries;
QStringList notices;
QStringList errors;
QString current_section;
QString normalized_text = text;
if (!normalized_text.isEmpty()
&& normalized_text.front() == QChar::ByteOrderMark)
{
normalized_text.remove(0, 1);
}
const QStringList lines = normalized_text.split(QLatin1Char('\n'));
for (int index = 0; index < lines.size(); ++index)
{
QString line = lines.at(index);
if (line.endsWith(QLatin1Char('\r')))
{
line.chop(1);
}
const QString trimmed = line.trimmed();
const int line_number = index + 1;
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';'))
|| trimmed.startsWith(QLatin1Char('#')))
{
continue;
}
if (trimmed.startsWith(QLatin1Char('[')))
{
if (!trimmed.endsWith(QLatin1Char(']')) || trimmed.size() < 3)
{
errors.append(
QStringLiteral("第 %1 行分区格式无效:%2")
.arg(line_number)
.arg(trimmed));
current_section.clear();
continue;
}
current_section = trimmed.mid(1, trimmed.size() - 2).trimmed();
if (current_section.isEmpty())
{
errors.append(QStringLiteral("第 %1 行分区名称不能为空").arg(line_number));
}
continue;
}

const int separator = trimmed.indexOf(QLatin1Char('='));
if (separator <= 0 || current_section.isEmpty())
{
errors.append(
QStringLiteral("第 %1 行字段格式无效:%2")
.arg(line_number)
.arg(trimmed));
continue;
}
const QString key = trimmed.left(separator).trimmed();
const QString value = trimmed.mid(separator + 1).trimmed();
if (key.isEmpty())
{
errors.append(QStringLiteral("第 %1 行字段名称不能为空").arg(line_number));
continue;
}
const QString path = current_section + QLatin1Char('.') + key;
if (entries.contains(path))
{
errors.append(
QStringLiteral("第 %1 行字段 %2 重复,首次出现在第 %3 行")
.arg(line_number)
.arg(path)
.arg(entries.value(path).line));
continue;
}
entries.insert(path, {value, line_number});
if (!known_fields.contains(path))
{
notices.append(
QStringLiteral("第 %1 行未知字段 %2 已忽略")
.arg(line_number)
.arg(path));
}
}

ApplicationSettings candidate;
qint64 parsed = 0;
if (readInteger(
entries, QStringLiteral("Config.Version"),
kCurrentConfigurationVersion, kCurrentConfigurationVersion,
kCurrentConfigurationVersion, &parsed, &notices, &errors, true))
{
candidate.version = static_cast<int>(parsed);
}

if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxHmiPages"),
1, ProjectLimits::kMaximumHmiPages,
ProjectLimits::kMaximumHmiPages, &parsed, &notices, &errors))
{
candidate.projectLimits.maximumHmiPages = static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"),
1, ProjectLimits::kMaximumHmiControlsPerPage,
ProjectLimits::kMaximumHmiControlsPerPage,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumHmiControlsPerPage =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxAlarmDefinitions"),
0, ProjectLimits::kMaximumAlarmDefinitions,
ProjectLimits::kMaximumAlarmDefinitions,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumAlarmDefinitions =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxControlLogics"),
1, ProjectLimits::kMaximumControlLogics,
ProjectLimits::kMaximumControlLogics,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumControlLogics =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxRungsPerLogic"),
0, ProjectLimits::kMaximumRungsPerLogic,
ProjectLimits::kMaximumRungsPerLogic,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumRungsPerLogic =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxOutputMessages"),
1, ProjectLimits::kMaximumOutputMessages,
ProjectLimits::kMaximumOutputMessages,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumOutputMessages = static_cast<int>(parsed);
}

if (readInteger(
entries, QStringLiteral("HmiDefaults.DefaultPageWidth"),
ProjectLimits::kMinimumHmiPageWidth,
ProjectLimits::kMaximumHmiPageWidth,
ProjectLimits::kDefaultHmiPageWidth,
&parsed, &notices, &errors))
{
candidate.hmiDefaults.pageWidth = static_cast<int>(parsed);
}
if (readInteger(
entries, QStringLiteral("HmiDefaults.DefaultPageHeight"),
ProjectLimits::kMinimumHmiPageHeight,
ProjectLimits::kMaximumHmiPageHeight,
ProjectLimits::kDefaultHmiPageHeight,
&parsed, &notices, &errors))
{
candidate.hmiDefaults.pageHeight = static_cast<int>(parsed);
}

const QString port_path = QStringLiteral("PlcDefaults.PortName");
const auto port_entry = entries.constFind(port_path);
if (port_entry == entries.cend())
{
notices.append(
QStringLiteral("配置字段 %1 缺失,使用代码默认值 COM3")
.arg(port_path));
}
else
{
const QByteArray port_bytes = port_entry->value.toUtf8();
if (port_entry->value.trimmed().isEmpty()
|| port_bytes.size() > static_cast<int>(ProjectLimits::kMaximumIdBytes))
{
errors.append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,端口名称不能为空且不能超过 128 个 UTF-8 字节")
.arg(port_entry->line)
.arg(port_path, port_entry->value));
}
else
{
candidate.plcDefaults.portName = toUtf8(port_entry->value.trimmed());
}
}

if (readInteger(
entries, QStringLiteral("PlcDefaults.ServerAddress"),
ProjectLimits::kMinimumPlcServerAddress,
ProjectLimits::kMaximumPlcServerAddress,
candidate.plcDefaults.serverAddress,
&parsed, &notices, &errors))
{
candidate.plcDefaults.serverAddress = static_cast<int>(parsed);
}

int baud_rate = candidate.plcDefaults.baudRate;
if (readInteger(
entries, QStringLiteral("PlcDefaults.BaudRate"),
9600, 115200, candidate.plcDefaults.baudRate,
&parsed, &notices, &errors))
{
baud_rate = static_cast<int>(parsed);
if (!containsValue({9600, 19200, 38400, 57600, 115200}, baud_rate))
{
addUnsupportedValueError(
entries, QStringLiteral("PlcDefaults.BaudRate"),
QStringLiteral("9600、19200、38400、57600 或 115200"), &errors);
}
else
{
candidate.plcDefaults.baudRate = baud_rate;
}
}

if (readInteger(
entries, QStringLiteral("PlcDefaults.DataBits"),
7, 8, candidate.plcDefaults.dataBits,
&parsed, &notices, &errors))
{
candidate.plcDefaults.dataBits = static_cast<int>(parsed);
}

int parity = candidate.plcDefaults.parity;
if (readInteger(
entries, QStringLiteral("PlcDefaults.Parity"),
0, 3, candidate.plcDefaults.parity,
&parsed, &notices, &errors))
{
parity = static_cast<int>(parsed);
if (!containsValue({0, 2, 3}, parity))
{
addUnsupportedValueError(
entries, QStringLiteral("PlcDefaults.Parity"),
QStringLiteral("0、2 或 3"), &errors);
}
else
{
candidate.plcDefaults.parity = parity;
}
}

if (readInteger(
entries, QStringLiteral("PlcDefaults.StopBits"),
1, 2, candidate.plcDefaults.stopBits,
&parsed, &notices, &errors))
{
candidate.plcDefaults.stopBits = static_cast<int>(parsed);
}

if (!errors.isEmpty())
{
ApplicationSettingsLoadResult result;
result.warningRequired = true;
result.warningMessage =
"应用配置存在严重错误,本次已全部采用代码默认值。详细原因请查看底部输出面板。";
for (const QString &error : errors)
{
result.messages.push_back(toUtf8(QStringLiteral("应用配置错误:") + error));
}
for (const QString &notice : notices)
{
result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice));
}
result.messages.push_back(
"应用配置:整份配置不生效,本次运行全部使用代码默认值");
return result;
}

ApplicationSettingsLoadResult result;
result.settings = candidate;
result.messages.push_back(toUtf8(
QStringLiteral("应用配置:已加载 %1,修改后需重启程序")
.arg(QDir::toNativeSeparators(file_path))));
for (const QString &notice : notices)
{
result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice));
}
return result;
}

+ 13
- 0
app/src/infrastructure/application_settings_loader.h 查看文件

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

#include "services/application_settings.h"

#include <QString>

// 从程序目录下的严格 UTF-8 INI 文件加载一次应用配置
class ApplicationSettingsLoader final
{
public:
static QString defaultFilePath();
static ApplicationSettingsLoadResult load(const QString &file_path);
};

+ 25
- 10
app/src/infrastructure/json_project_storage.cpp 查看文件

@@ -212,7 +212,9 @@ bool readArray(
{
return state->fail(
ProjectStorageError::InvalidField,
fieldPath(context, field) + " 的元素数量超过上限");
fieldPath(context, field) + " 的元素数量为 "
+ std::to_string(value->size()) + ",当前上限为 "
+ std::to_string(maximum_count));
}
return true;
}
@@ -771,6 +773,7 @@ QJsonObject serializeHmiPage(const HmiPage &page)
bool parseHmiPage(
const QJsonObject &object,
const std::string &context,
const ProjectLimitSettings &limits,
HmiPage *page,
ParseState *state)
{
@@ -789,7 +792,7 @@ bool parseHmiPage(
&page->height, state)
|| !readArray(
object, "controls", context, &controls, state,
static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage)))
static_cast<int>(limits.maximumHmiControlsPerPage)))
{
return false;
}
@@ -1528,6 +1531,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic)
bool parseControlLogic(
const QJsonObject &object,
const std::string &context,
const ProjectLimitSettings &limits,
ControlLogic *logic,
ParseState *state)
{
@@ -1539,7 +1543,7 @@ bool parseControlLogic(
|| !readBool(object, "enabled", context, &logic->enabled, state)
|| !readArray(
object, "rungs", context, &rungs, state,
static_cast<int>(ProjectLimits::kMaximumRungsPerLogic)))
static_cast<int>(limits.maximumRungsPerLogic)))
{
return false;
}
@@ -1608,7 +1612,10 @@ QJsonObject serializeProject(const Project &project)

// 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本
bool parseProject(
const QJsonObject &object, Project *project, ParseState *state)
const QJsonObject &object,
const ProjectLimitSettings &limits,
Project *project,
ParseState *state)
{
QJsonArray pages;
QJsonArray alarms;
@@ -1638,20 +1645,20 @@ bool parseProject(
|| !readString(object, "name", "project", &project->metadata.name, state)
|| !readArray(
object, "hmiPages", "project", &pages, state,
static_cast<int>(ProjectLimits::kMaximumHmiPages))
static_cast<int>(limits.maximumHmiPages))
|| !readString(
object, "initialHmiPageId", "project",
&project->initialHmiPageId, state,
ProjectLimits::kMaximumIdBytes)
|| !readArray(
object, "alarmDefinitions", "project", &alarms, state,
static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions))
static_cast<int>(limits.maximumAlarmDefinitions))
|| !readArray(
object, "registerComments", "project", &register_comments, state,
static_cast<int>(ProjectLimits::kMaximumRegisterComments))
|| !readArray(
object, "controlLogics", "project", &logics, state,
static_cast<int>(ProjectLimits::kMaximumControlLogics)))
static_cast<int>(limits.maximumControlLogics)))
{
return false;
}
@@ -1670,6 +1677,7 @@ bool parseProject(
if (!parseHmiPage(
pages.at(index).toObject(),
"project.hmiPages[" + std::to_string(index) + ']',
limits,
&page,
state))
{
@@ -1733,6 +1741,7 @@ bool parseProject(
if (!parseControlLogic(
logics.at(index).toObject(),
"project.controlLogics[" + std::to_string(index) + ']',
limits,
&logic,
state))
{
@@ -1752,13 +1761,19 @@ ProjectSaveResult saveFailure(

} // namespace

JsonProjectStorage::JsonProjectStorage(
const ProjectLimitSettings &project_limits)
: project_limits_(project_limits)
{
}

// 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件
ProjectSaveResult JsonProjectStorage::save(
const Project &project, const std::string &file_path)
{
// 写文件前先执行领域校验,防止持久化内部关系不合法的工程
std::string validation_error;
if (!project.validate(&validation_error))
if (!project.validate(project_limits_, &validation_error))
{
return {false, ProjectStorageError::InvalidProject, validation_error};
}
@@ -1856,13 +1871,13 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
// 先在局部对象中完成结构解析,失败时不会暴露半成品工程
Project project;
ParseState state;
if (!parseProject(document.object(), &project, &state))
if (!parseProject(document.object(), project_limits_, &project, &state))
{
return {false, {}, state.error, state.message};
}
// JSON 字段合法不代表业务关系合法,还需执行领域层整体校验
std::string validation_error;
if (!project.validate(&validation_error))
if (!project.validate(project_limits_, &validation_error))
{
return {false,
{},


+ 6
- 0
app/src/infrastructure/json_project_storage.h 查看文件

@@ -6,7 +6,13 @@
class JsonProjectStorage final : public ProjectStorage
{
public:
explicit JsonProjectStorage(
const ProjectLimitSettings &project_limits =
defaultProjectLimitSettings());
ProjectSaveResult save(
const Project &project, const std::string &file_path) override;
ProjectLoadResult load(const std::string &file_path) override;

private:
const ProjectLimitSettings &project_limits_;
};

+ 12
- 4
app/src/main.cpp 查看文件

@@ -9,6 +9,7 @@
#include <QApplication>

#include "infrastructure/json_project_storage.h"
#include "infrastructure/application_settings_loader.h"
#include "infrastructure/plc_communication_service.h"
#include "infrastructure/plc_discovery_service.h"
#include "infrastructure/plc_register_repository.h"
@@ -39,12 +40,18 @@ int main(int argc, char *argv[])
// 设置组织名称,用来区分这个程序保存的 Qt 配置
application.setOrganizationName(QStringLiteral("QtProXinJe"));

const ApplicationSettingsLoadResult application_settings =
ApplicationSettingsLoader::load(
ApplicationSettingsLoader::defaultFilePath());

// 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程
JsonProjectStorage project_storage;
JsonProjectStorage project_storage(application_settings.settings.projectLimits);
// 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象
ProjectService project_service(project_storage);
ProjectService project_service(
project_storage, application_settings.settings.projectLimits);
// 处理 HMI 页面和控件的添加、修改、删除、撤销与重做
HmiEditorService hmi_editor_service(project_service);
HmiEditorService hmi_editor_service(
project_service, application_settings.settings.hmiDefaults);
// 处理运行画面在不同 HMI 页面之间的跳转
HmiNavigationService hmi_navigation_service(project_service);
// 处理梯形图网络和节点的编辑、校验、撤销与重做
@@ -95,7 +102,8 @@ int main(int argc, char *argv[])
hmi_navigation_service,
register_comment_service,
register_monitor_service,
plc_discovery_service);
plc_discovery_service,
application_settings);
// 把主窗口显示到屏幕上
main_window.show();



+ 6
- 2
app/src/services/alarm_editor_service.cpp 查看文件

@@ -37,9 +37,13 @@ const AlarmDefinition *AlarmEditorService::findDefinition(
AlarmEditorResult AlarmEditorService::addDefinition(
const AlarmDefinition &definition)
{
if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions)
const std::size_t maximum =
project_service_.projectLimits().maximumAlarmDefinitions;
if (definitions().size() >= maximum)
{
return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 256 条报警定义");
return failure(
AlarmEditorError::InvalidDefinition,
"当前配置最多允许 " + std::to_string(maximum) + " 条报警定义");
}
AlarmDefinition candidate = definition;
candidate.id = makeUniqueId();


+ 30
- 0
app/src/services/application_settings.h 查看文件

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

#include "domain/project_limits.h"
#include "plc_communication_gateway.h"

#include <string>
#include <vector>

// 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好
struct ApplicationSettings
{
int version = 1;
ProjectLimitSettings projectLimits;
HmiDefaultSettings hmiDefaults;
PlcSerialConfiguration plcDefaults = []
{
PlcSerialConfiguration configuration;
configuration.portName = "COM3";
return configuration;
}();
};

// 配置加载结果同时携带主界面启动后需要显示的诊断信息
struct ApplicationSettingsLoadResult
{
ApplicationSettings settings;
std::vector<std::string> messages;
bool warningRequired = false;
std::string warningMessage;
};

+ 29
- 10
app/src/services/hmi_editor_service.cpp 查看文件

@@ -63,8 +63,11 @@ std::size_t totalControlCount(const Project &project)

} // namespace

HmiEditorService::HmiEditorService(ProjectService &project_service)
: project_service_(project_service)
HmiEditorService::HmiEditorService(
ProjectService &project_service,
const HmiDefaultSettings &hmi_defaults)
: project_service_(project_service),
hmi_defaults_(hmi_defaults)
{
}

@@ -199,6 +202,8 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
HmiPage page;
page.id = "page-1";
page.name = "主操作页面";
page.width = hmi_defaults_.pageWidth;
page.height = hmi_defaults_.pageHeight;

Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
@@ -214,9 +219,13 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空");
}
const Project &current = project_service_.project();
if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages)
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (current.hmiPages.size() >= limits.maximumHmiPages)
{
return failure(HmiEditorError::InvalidPage, "单个工程最多包含 32 个 HMI 页面");
return failure(
HmiEditorError::InvalidPage,
"当前配置最多允许 "
+ std::to_string(limits.maximumHmiPages) + " 个 HMI 页面");
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
@@ -233,6 +242,8 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
HmiPage page;
page.id = makeUniquePageId(current);
page.name = name;
page.width = hmi_defaults_.pageWidth;
page.height = hmi_defaults_.pageHeight;
HistoryState before = captureState();
Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
@@ -262,7 +273,7 @@ HmiEditorResult HmiEditorService::resizePage(
candidate.width = width;
candidate.height = height;
std::string error;
if (!candidate.validate(&error))
if (!candidate.validate(project_service_.projectLimits(), &error))
{
return failure(HmiEditorError::InvalidPage, error);
}
@@ -426,9 +437,13 @@ HmiEditorResult HmiEditorService::addControl(
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage)
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (page->controls.size() >= limits.maximumHmiControlsPerPage)
{
return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 128 个控件");
return failure(
HmiEditorError::InvalidControl,
"当前配置下单个 HMI 页面最多允许 "
+ std::to_string(limits.maximumHmiControlsPerPage) + " 个控件");
}
if (totalControlCount(project_service_.project())
>= ProjectLimits::kMaximumHmiControlsPerProject)
@@ -624,11 +639,15 @@ HmiEditorResult HmiEditorService::pasteControls(
{
return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件");
}
if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (controls.size() > limits.maximumHmiControlsPerPage
|| existing_page->controls.size() + controls.size()
> ProjectLimits::kMaximumHmiControlsPerPage)
> limits.maximumHmiControlsPerPage)
{
return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 128 个控件");
return failure(
HmiEditorError::InvalidControl,
"当前配置下单个 HMI 页面最多允许 "
+ std::to_string(limits.maximumHmiControlsPerPage) + " 个控件");
}
if (totalControlCount(project_service_.project()) + controls.size()
> ProjectLimits::kMaximumHmiControlsPerProject)


+ 4
- 1
app/src/services/hmi_editor_service.h 查看文件

@@ -59,7 +59,9 @@ public:
* @brief 创建 HMI 编辑服务
* @param project_service 用于读取和原子修改当前工程的项目服务
*/
explicit HmiEditorService(ProjectService &project_service);
explicit HmiEditorService(
ProjectService &project_service,
const HmiDefaultSettings &hmi_defaults = defaultHmiSettings());

/**
* @brief 按页面标识查找只读页面
@@ -290,5 +292,6 @@ private:
* @brief 非拥有的工程服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
const HmiDefaultSettings &hmi_defaults_;
EditorHistory<HistoryState> history_;
};

+ 29
- 12
app/src/services/logic_editor_service.cpp 查看文件

@@ -55,10 +55,14 @@ std::size_t totalRungCount(const Project &project)
return total;
}

std::string rungLimitMessage(const Project &project, const ControlLogic &logic)
{
return logic.rungs.size() >= ProjectLimits::kMaximumRungsPerLogic
? "单组控制逻辑最多包含 256 个网络"
std::string rungLimitMessage(
const Project &project,
const ControlLogic &logic,
const ProjectLimitSettings &limits)
{
return logic.rungs.size() >= limits.maximumRungsPerLogic
? "当前配置下单组控制逻辑最多允许 "
+ std::to_string(limits.maximumRungsPerLogic) + " 个网络"
: totalRungCount(project) >= ProjectLimits::kMaximumRungsPerProject
? "单个工程最多包含 2048 个梯形图网络"
: std::string{};
@@ -974,9 +978,13 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name)
return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空");
}
const Project &current = project_service_.project();
if (current.controlLogics.size() >= ProjectLimits::kMaximumControlLogics)
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (current.controlLogics.size() >= limits.maximumControlLogics)
{
return failure(LogicEditorError::InvalidOperation, "单个工程最多包含 32 组控制逻辑");
return failure(
LogicEditorError::InvalidOperation,
"当前配置最多允许 "
+ std::to_string(limits.maximumControlLogics) + " 组控制逻辑");
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
@@ -1130,7 +1138,7 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id)
return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑");
}
const std::string limit_message = rungLimitMessage(
project_service_.project(), *logic);
project_service_.project(), *logic, project_service_.projectLimits());
if (!limit_message.empty())
{
return failure(LogicEditorError::InvalidOperation, limit_message);
@@ -1224,7 +1232,9 @@ LogicEditorResult LogicEditorService::appendCondition(
return failure(LogicEditorError::RungNotFound, "未找到梯形图网络");
}
const std::string limit_message = create_rung
? rungLimitMessage(project_service_.project(), *logic) : std::string{};
? rungLimitMessage(
project_service_.project(), *logic, project_service_.projectLimits())
: std::string{};
if (!limit_message.empty())
{
return failure(LogicEditorError::InvalidOperation, limit_message);
@@ -1571,7 +1581,9 @@ LogicEditorResult LogicEditorService::appendWire(
create_rung = true;
}
const std::string limit_message = create_rung
? rungLimitMessage(project_service_.project(), *logic) : std::string{};
? rungLimitMessage(
project_service_.project(), *logic, project_service_.projectLimits())
: std::string{};
if (!limit_message.empty())
{
return failure(LogicEditorError::InvalidOperation, limit_message);
@@ -2468,7 +2480,9 @@ LogicEditorResult LogicEditorService::setOutput(
: "梯形图输出必须使用有效的输出指令");
}
const std::string limit_message = rung_id.empty()
? rungLimitMessage(project_service_.project(), *logic) : std::string{};
? rungLimitMessage(
project_service_.project(), *logic, project_service_.projectLimits())
: std::string{};
if (!limit_message.empty())
{
return failure(LogicEditorError::InvalidOperation, limit_message);
@@ -2612,7 +2626,9 @@ LogicEditorResult LogicEditorService::pasteConditionNodes(
return failure(LogicEditorError::RungNotFound, "未找到梯形图网络");
}
const std::string limit_message = rung_id.empty()
? rungLimitMessage(project_service_.project(), *existing_logic)
? rungLimitMessage(
project_service_.project(), *existing_logic,
project_service_.projectLimits())
: std::string{};
if (!limit_message.empty())
{
@@ -2742,7 +2758,8 @@ LogicEditorResult LogicEditorService::pasteRung(
return failure(LogicEditorError::InvalidOperation, source_error);
}
const std::string limit_message = rungLimitMessage(
project_service_.project(), *existing_logic);
project_service_.project(), *existing_logic,
project_service_.projectLimits());
if (!limit_message.empty())
{
return failure(LogicEditorError::InvalidOperation, limit_message);


+ 15
- 2
app/src/services/project_service.cpp 查看文件

@@ -25,7 +25,15 @@ std::string generateProjectId()
} // namespace

ProjectService::ProjectService(ProjectStorage &storage)
: ProjectService(storage, defaultProjectLimitSettings())
{
}

ProjectService::ProjectService(
ProjectStorage &storage,
const ProjectLimitSettings &project_limits)
: storage_(storage),
project_limits_(project_limits),
project_(makeNewProject("未命名工程"))
{
}
@@ -57,6 +65,11 @@ bool ProjectService::isModified() const
return modified_;
}

const ProjectLimitSettings &ProjectService::projectLimits() const
{
return project_limits_;
}

void ProjectService::restoreModifiedState(bool modified)
{
modified_ = modified;
@@ -103,7 +116,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
}

std::string validation_error;
if (!project_.validate(&validation_error))
if (!project_.validate(project_limits_, &validation_error))
{
return {false,
ProjectServiceError::InvalidProject,
@@ -142,7 +155,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
}

std::string validation_error;
if (!result.project.validate(&validation_error))
if (!result.project.validate(project_limits_, &validation_error))
{
return {false,
ProjectServiceError::InvalidProject,


+ 5
- 0
app/src/services/project_service.h 查看文件

@@ -29,6 +29,9 @@ class ProjectService
public:
// 使用外部提供的工程存储实现创建服务
explicit ProjectService(ProjectStorage &storage);
ProjectService(
ProjectStorage &storage,
const ProjectLimitSettings &project_limits);

// 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它
const Project &project() const;
@@ -37,6 +40,7 @@ public:
const std::string &currentFilePath() const;
bool hasCurrentFile() const;
bool isModified() const;
const ProjectLimitSettings &projectLimits() const;
// 失败编辑回滚时恢复进入编辑前的脏状态
void restoreModifiedState(bool modified);

@@ -74,6 +78,7 @@ private:
ProjectStorageError error, const std::string &message);

ProjectStorage &storage_; // 非拥有的工程存储依赖
const ProjectLimitSettings &project_limits_; // 应用启动时确定的只读数量上限
Project project_; // 当前编辑中的工程
std::string current_file_path_; // 当前工程对应的文件路径
bool modified_ = true; // 当前工程是否有未保存修改


+ 2
- 1
app/src/services/runtime_mode_service.cpp 查看文件

@@ -63,7 +63,8 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
return state_.enterOfflineRunning();
}
std::string error;
if (!project_service_.project().validateForRunning(&error))
if (!project_service_.project().validateForRunning(
project_service_.projectLimits(), &error))
{
return {false, ModeTransitionError::ProjectNotReady};
}


+ 29
- 3
app/src/ui/main_window.cpp 查看文件

@@ -48,6 +48,7 @@
#include <QToolBar>
#include <QToolButton>
#include <QTextEdit>
#include <QTimer>

#include <algorithm>

@@ -262,6 +263,7 @@ MainWindow::MainWindow(
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
QWidget *parent)
: QMainWindow(parent),
ui_(std::make_unique<Ui::MainWindow>()),
@@ -275,9 +277,11 @@ MainWindow::MainWindow(
register_comment_service_(register_comment_service),
register_monitor_service_(register_monitor_service),
plc_discovery_gateway_(plc_discovery_gateway),
application_settings_result_(application_settings_result),
owned_hmi_navigation_service_(
std::make_unique<HmiNavigationService>(project_service)),
hmi_navigation_service_(owned_hmi_navigation_service_.get())
hmi_navigation_service_(owned_hmi_navigation_service_.get()),
plc_configuration_(application_settings_result.settings.plcDefaults)
{
initializeUi();
}
@@ -294,6 +298,7 @@ MainWindow::MainWindow(
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
QWidget *parent)
: QMainWindow(parent),
ui_(std::make_unique<Ui::MainWindow>()),
@@ -307,7 +312,9 @@ MainWindow::MainWindow(
register_comment_service_(register_comment_service),
register_monitor_service_(register_monitor_service),
plc_discovery_gateway_(plc_discovery_gateway),
hmi_navigation_service_(&hmi_navigation_service)
application_settings_result_(application_settings_result),
hmi_navigation_service_(&hmi_navigation_service),
plc_configuration_(application_settings_result.settings.plcDefaults)
{
initializeUi();
}
@@ -323,6 +330,7 @@ void MainWindow::initializeUi()
project_service_,
hmi_editor_service_,
logic_editor_service_,
application_settings_result_.settings.hmiDefaults,
[this] { return current_hmi_page_id_; },
[this] { return current_logic_id_; },
selected_control_id_,
@@ -355,6 +363,22 @@ void MainWindow::initializeUi()
showControlProperties({});
refreshProjectUi();
updateModeUi(tr("系统已进入编辑态"));
for (const std::string &message : application_settings_result_.messages)
{
appendOutputMessage(fromUtf8(message));
}
if (application_settings_result_.warningRequired)
{
const QString warning = fromUtf8(
application_settings_result_.warningMessage);
QTimer::singleShot(
0,
this,
[this, warning]
{
QMessageBox::warning(this, tr("应用配置"), warning);
});
}
}

MainWindow::~MainWindow()
@@ -1658,7 +1682,9 @@ void MainWindow::showProjectResult(

void MainWindow::appendOutputMessage(const QString &message)
{
while (ui_->outputList->count() >= ProjectLimits::kMaximumOutputMessages)
const int maximum = application_settings_result_
.settings.projectLimits.maximumOutputMessages;
while (ui_->outputList->count() >= maximum)
{
delete ui_->outputList->takeItem(0);
}


+ 5
- 0
app/src/ui/main_window.h 查看文件

@@ -12,6 +12,7 @@
#include "domain/control_logic_model.h"
#include "domain/runtime_state.h"
#include "services/plc_communication_gateway.h"
#include "services/application_settings.h"

#include <QMainWindow>

@@ -79,6 +80,7 @@ public:
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
QWidget *parent = nullptr);
/**
* @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务
@@ -97,6 +99,7 @@ public:
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
QWidget *parent = nullptr);

/** 释放窗口拥有的控制器和可选导航服务 */
@@ -256,6 +259,8 @@ private:
RegisterMonitorService &register_monitor_service_;
/** 非拥有的 PLC 自动搜索服务依赖 */
PlcDiscoveryGateway &plc_discovery_gateway_;
/** 启动期配置和诊断结果,由应用入口保证生命周期 */
const ApplicationSettingsLoadResult &application_settings_result_;
/** 当调用方未提供导航服务时由主窗口负责拥有的实例 */
std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_;
/** 当前使用的 HMI 页面导航服务 */


+ 4
- 2
app/src/ui/property_panel_controller.cpp 查看文件

@@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController(
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
const HmiDefaultSettings &hmi_defaults,
std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id,
std::string &selected_control_id,
@@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController(
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
logic_editor_service_(logic_editor_service),
hmi_defaults_(hmi_defaults),
current_page_id_(std::move(current_page_id)),
current_logic_id_(std::move(current_logic_id)),
selected_control_id_(selected_control_id),
@@ -331,8 +333,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id)
ui_.applyPagePropertiesButton->setEnabled(has_page);
if (!has_page)
{
ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth);
ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight);
ui_.pageWidthSpinBox->setValue(hmi_defaults_.pageWidth);
ui_.pageHeightSpinBox->setValue(hmi_defaults_.pageHeight);
return;
}
ui_.pageWidthSpinBox->setValue(page->width);


+ 3
- 0
app/src/ui/property_panel_controller.h 查看文件

@@ -49,6 +49,7 @@ public:
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
const HmiDefaultSettings &hmi_defaults,
std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id,
std::string &selected_control_id,
@@ -102,6 +103,8 @@ private:
HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** 新建页面的启动期只读默认尺寸 */
const HmiDefaultSettings &hmi_defaults_;
/** 获取当前 HMI 页面标识的回调 */
std::function<std::string()> current_page_id_;
/** 获取当前控制逻辑标识的回调 */


+ 17
- 0
app/tests/alarm_service_tests.cpp 查看文件

@@ -102,6 +102,22 @@ void testAlarmDefinitionsAndRuntimeLifecycle()
"leaving runtime must clear session alarm records");
}

void testConfiguredAlarmLimit()
{
TestProjectStorage storage;
ProjectLimitSettings limits;
limits.maximumAlarmDefinitions = 0U;
ProjectService project_service(storage, limits);
AlarmEditorService editor_service(project_service);

AlarmDefinition definition;
definition.address = RegisterAddress{RegisterArea::M, 0};
definition.condition = AlarmCondition::MOn;
definition.message = "Disabled by configuration";
require(!editor_service.addDefinition(definition).succeeded,
"the alarm editor must use the configured definition limit");
}

} // namespace

int main()
@@ -109,6 +125,7 @@ int main()
try
{
testAlarmDefinitionsAndRuntimeLifecycle();
testConfiguredAlarmLimit();
}
catch (const std::exception &error)
{


+ 252
- 0
app/tests/application_settings_tests.cpp 查看文件

@@ -0,0 +1,252 @@
#include "infrastructure/application_settings_loader.h"
#include "support/test_support.h"

#include <QCoreApplication>
#include <QFile>
#include <QTemporaryDir>

#include <algorithm>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

namespace {

using TestSupport::require;

void writeUtf8(const QString &path, const QByteArray &contents)
{
QFile file(path);
require(file.open(QIODevice::WriteOnly), "test configuration file must open");
require(file.write(contents) == contents.size(),
"test configuration file must be written completely");
}

bool containsMessage(
const ApplicationSettingsLoadResult &result,
const std::string &fragment)
{
return std::any_of(
result.messages.cbegin(), result.messages.cend(),
[&fragment](const std::string &message)
{
return message.find(fragment) != std::string::npos;
});
}

QByteArray readAll(const QString &path)
{
QFile file(path);
require(file.open(QIODevice::ReadOnly), "test configuration file must open for reading");
return file.readAll();
}

QByteArray completeConfiguration()
{
return QByteArrayLiteral(
"[Config]\n"
"Version=1\n"
"[ProjectLimits]\n"
"MaxHmiPages=8\n"
"MaxHmiControlsPerPage=24\n"
"MaxAlarmDefinitions=40\n"
"MaxControlLogics=6\n"
"MaxRungsPerLogic=30\n"
"MaxOutputMessages=120\n"
"[HmiDefaults]\n"
"DefaultPageWidth=1024\n"
"DefaultPageHeight=600\n"
"[PlcDefaults]\n"
"PortName=COM7\n"
"ServerAddress=2\n"
"BaudRate=19200\n"
"DataBits=7\n"
"Parity=3\n"
"StopBits=2\n");
}

void testMissingFileCreatesDefaults()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
const QString path = directory.filePath("config/application.ini");

const ApplicationSettingsLoadResult result =
ApplicationSettingsLoader::load(path);
require(QFile::exists(path), "a missing application.ini must be created");
require(!result.warningRequired,
"successful first-run configuration creation must not warn");
require(result.settings.projectLimits.maximumHmiPages == 32U
&& result.settings.hmiDefaults.pageWidth == 800
&& result.settings.plcDefaults.portName == "COM3",
"first run must use code defaults");
const QByteArray created = readAll(path);
require(created.contains("[HmiDefaults]\n")
&& created.contains("DefaultPageWidth=800\n")
&& created.contains("DefaultPageHeight=400\n")
&& created.contains("[PlcDefaults]\n")
&& created.contains("PortName=COM3\n"),
"the generated file must expose HMI dimensions and PLC defaults");
require(containsMessage(result, "已创建默认配置文件"),
"first run must report the created file");
}

void testCompleteConfigurationLoadsEveryPublicField()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
const QString path = directory.filePath("application.ini");
writeUtf8(path, completeConfiguration());

const ApplicationSettingsLoadResult result =
ApplicationSettingsLoader::load(path);
const ApplicationSettings &settings = result.settings;
require(!result.warningRequired, "a valid configuration must be accepted");
require(settings.projectLimits.maximumHmiPages == 8U
&& settings.projectLimits.maximumHmiControlsPerPage == 24U
&& settings.projectLimits.maximumAlarmDefinitions == 40U
&& settings.projectLimits.maximumControlLogics == 6U
&& settings.projectLimits.maximumRungsPerLogic == 30U
&& settings.projectLimits.maximumOutputMessages == 120,
"all configurable project limits must be loaded");
require(settings.hmiDefaults.pageWidth == 1024
&& settings.hmiDefaults.pageHeight == 600,
"HMI default dimensions must be loaded");
require(settings.plcDefaults.portName == "COM7"
&& settings.plcDefaults.serverAddress == 2
&& settings.plcDefaults.baudRate == 19200
&& settings.plcDefaults.dataBits == 7
&& settings.plcDefaults.parity == 3
&& settings.plcDefaults.stopBits == 2,
"PLC defaults must be loaded");
}

void testMissingAndUnknownFieldsDoNotInvalidateConfiguration()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
const QString path = directory.filePath("application.ini");
writeUtf8(
path,
QByteArrayLiteral(
"[Config]\nVersion=1\n"
"[ProjectLimits]\nMaxHmiPages=5\nFutureLimit=99\n"));

const ApplicationSettingsLoadResult result =
ApplicationSettingsLoader::load(path);
require(!result.warningRequired
&& result.settings.projectLimits.maximumHmiPages == 5U
&& result.settings.projectLimits.maximumAlarmDefinitions == 256U,
"missing fields must default without discarding valid values");
require(containsMessage(result, "未知字段")
&& containsMessage(result, "缺失"),
"missing and unknown fields must be reported");
}

void testDuplicateOrInvalidFieldsRejectTheWholeFile()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
const QString duplicate_path = directory.filePath("duplicate.ini");
QByteArray duplicate = completeConfiguration();
duplicate.append("[ProjectLimits]\nMaxHmiPages=4\n");
writeUtf8(duplicate_path, duplicate);

const ApplicationSettingsLoadResult duplicate_result =
ApplicationSettingsLoader::load(duplicate_path);
require(duplicate_result.warningRequired
&& duplicate_result.settings.projectLimits.maximumHmiPages == 32U
&& containsMessage(duplicate_result, "重复"),
"a duplicate field must reject the entire configuration");

const QString invalid_path = directory.filePath("invalid.ini");
QByteArray invalid = completeConfiguration();
invalid.replace("DefaultPageWidth=1024", "DefaultPageWidth=2000");
writeUtf8(invalid_path, invalid);
const ApplicationSettingsLoadResult invalid_result =
ApplicationSettingsLoader::load(invalid_path);
require(invalid_result.warningRequired
&& invalid_result.settings.hmiDefaults.pageWidth == 800
&& invalid_result.settings.plcDefaults.portName == "COM3",
"one invalid value must roll every section back to code defaults");

const QString non_integer_path = directory.filePath("non-integer.ini");
QByteArray non_integer = completeConfiguration();
non_integer.replace("MaxHmiPages=8", "MaxHmiPages=8pages");
writeUtf8(non_integer_path, non_integer);
const ApplicationSettingsLoadResult non_integer_result =
ApplicationSettingsLoader::load(non_integer_path);
require(non_integer_result.warningRequired
&& non_integer_result.settings.projectLimits.maximumHmiPages == 32U
&& containsMessage(non_integer_result, "8pages"),
"quantity settings must reject values that are not pure integers");
}

void testVersionAndEncodingErrorsRejectTheWholeFile()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

const QString version_path = directory.filePath("future-version.ini");
QByteArray future_version = completeConfiguration();
future_version.replace("Version=1", "Version=2");
writeUtf8(version_path, future_version);
const ApplicationSettingsLoadResult version_result =
ApplicationSettingsLoader::load(version_path);
require(version_result.warningRequired
&& version_result.settings.projectLimits.maximumHmiPages == 32U
&& containsMessage(version_result, "Config.Version")
&& containsMessage(version_result, "允许范围为 1~1"),
"an incompatible configuration version must reject the whole file");

const QString encoding_path = directory.filePath("invalid-utf8.ini");
QByteArray invalid_utf8("[Config]\nVersion=1\n# ");
invalid_utf8.append(static_cast<char>(0xFF));
writeUtf8(encoding_path, invalid_utf8);
const ApplicationSettingsLoadResult encoding_result =
ApplicationSettingsLoader::load(encoding_path);
require(encoding_result.warningRequired
&& containsMessage(encoding_result, "UTF-8")
&& encoding_result.settings.plcDefaults.portName == "COM3",
"invalid UTF-8 must fall back to all code defaults");
}

void testCreationFailureKeepsTheApplicationStartable()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
const QString blocker = directory.filePath("not-a-directory");
writeUtf8(blocker, QByteArrayLiteral("block"));

const ApplicationSettingsLoadResult result =
ApplicationSettingsLoader::load(blocker + "/application.ini");
require(result.warningRequired
&& result.settings.projectLimits.maximumOutputMessages == 1000
&& containsMessage(result, "使用代码默认值"),
"configuration creation failure must fall back without aborting startup");
}

} // namespace

int main(int argc, char *argv[])
{
QCoreApplication application(argc, argv);
try
{
testMissingFileCreatesDefaults();
testCompleteConfigurationLoadsEveryPublicField();
testMissingAndUnknownFieldsDoNotInvalidateConfiguration();
testDuplicateOrInvalidFieldsRejectTheWholeFile();
testVersionAndEncodingErrorsRejectTheWholeFile();
testCreationFailureKeepsTheApplicationStartable();
}
catch (const std::exception &error)
{
std::cerr << "application settings tests failed: "
<< error.what() << '\n';
return 1;
}
std::cout << "application settings tests passed\n";
return 0;
}

+ 17
- 0
app/tests/application_settings_tests.pro 查看文件

@@ -0,0 +1,17 @@
include(pri/test_defaults.pri)

TARGET = application_settings_tests
QT += core
CONFIG += testcase

SOURCES += \
application_settings_tests.cpp \
../src/infrastructure/application_settings_loader.cpp

HEADERS += \
../src/infrastructure/application_settings_loader.h \
../src/services/application_settings.h \
../src/services/plc_communication_gateway.h \
../src/domain/project_limits.h \
../src/domain/register_address.h \
$$TEST_SUPPORT_HEADERS

+ 50
- 0
app/tests/domain_tests.cpp 查看文件

@@ -801,6 +801,55 @@ void testRuntimeStateBoundaries()
require(state.policy().usesPlcRegisters, "online mode must use PLC registers");
}

void testRuntimeConfiguredProjectLimits()
{
ProjectLimitSettings limits;
std::string error;

Project project = makeValidProject();
HmiPage second_page = project.hmiPages.front();
second_page.id = "second-page";
second_page.name = "Second page";
project.hmiPages.push_back(second_page);
limits.maximumHmiPages = 1U;
require(!project.validate(limits, &error)
&& error.find("当前配置上限为 1") != std::string::npos,
"runtime page limits must be enforced by aggregate validation");

project = makeValidProject();
HmiControl second_control = project.hmiPages.front().controls.front();
second_control.id = "second-control";
project.hmiPages.front().controls.push_back(second_control);
limits = {};
limits.maximumHmiControlsPerPage = 1U;
require(!project.validate(limits, &error),
"runtime per-page control limits must be enforced");

project = makeValidProject();
project.alarmDefinitions.push_back({});
limits = {};
limits.maximumAlarmDefinitions = 0U;
require(!project.validate(limits, &error),
"runtime alarm limits must be enforced before child validation");

project = makeValidProject();
ControlLogic second_logic = project.controlLogics.front();
second_logic.id = "second-logic";
second_logic.name = "Second logic";
second_logic.rungs.clear();
project.controlLogics.push_back(second_logic);
limits = {};
limits.maximumControlLogics = 1U;
require(!project.validate(limits, &error),
"runtime control-logic limits must be enforced");

project = makeValidProject();
limits = {};
limits.maximumRungsPerLogic = 0U;
require(!project.validate(limits, &error),
"runtime per-logic rung limits must be enforced");
}

} // namespace

int main()
@@ -820,6 +869,7 @@ int main()
testModelsValidateBindingsAndIdentifiers();
testMultiPageAndLogicDomainRules();
testQuantityBoundaries();
testRuntimeConfiguredProjectLimits();
testRuntimeStateBoundaries();
}
catch (const std::exception &error)


+ 25
- 0
app/tests/hmi_editor_service_tests.cpp 查看文件

@@ -449,6 +449,30 @@ void testPageResizeIsAtomicAndUndoable()
"page width below the business minimum must be rejected");
}

void testConfiguredPageDefaultsAndLimits()
{
TestProjectStorage storage;
ProjectLimitSettings limits;
limits.maximumHmiPages = 1U;
limits.maximumHmiControlsPerPage = 1U;
HmiDefaultSettings defaults;
defaults.pageWidth = 1024;
defaults.pageHeight = 600;
ProjectService project_service(storage, limits);
HmiEditorService service(project_service, defaults);

const HmiEditorResult page = service.ensureDefaultPage();
require(page.succeeded
&& service.findPage(page.id)->width == 1024
&& service.findPage(page.id)->height == 600,
"configured HMI dimensions must initialize the default page");
require(!service.addPage("Second page").succeeded,
"the HMI editor must use the configured page limit");
require(service.addControl(page.id, HmiControlType::Label).succeeded
&& !service.addControl(page.id, HmiControlType::Label).succeeded,
"the HMI editor must use the configured per-page control limit");
}

} // namespace

int main()
@@ -464,6 +488,7 @@ int main()
testRuntimeUsesRegisterRepository();
testPageLifecycleAndNavigation();
testPageResizeIsAtomicAndUndoable();
testConfiguredPageDefaultsAndLimits();
}
catch (const std::exception &error)
{


+ 19
- 0
app/tests/logic_editor_service_tests.cpp 查看文件

@@ -1440,6 +1440,24 @@ void testConditionPasteTargetsAndValidation()
"an invalid paste target must roll back without recording history");
}

void testConfiguredLogicLimits()
{
TestProjectStorage storage;
ProjectLimitSettings limits;
limits.maximumControlLogics = 1U;
limits.maximumRungsPerLogic = 0U;
ProjectService project_service(storage, limits);
LogicEditorService service(project_service);

const LogicEditorResult initial = service.ensureDefaultLogic();
require(initial.succeeded,
"the configured-limit fixture must create its one default logic");
require(!service.addLogic("Second logic").succeeded,
"the logic editor must use the configured logic-group limit");
require(!service.addRung(initial.id).succeeded,
"the logic editor must use the configured per-logic rung limit");
}

} // namespace

int main()
@@ -1466,6 +1484,7 @@ int main()
testHistoryAndAtomicBatchDelete();
testBatchPasteNodesAndRung();
testConditionPasteTargetsAndValidation();
testConfiguredLogicLimits();
}
catch (const std::exception &error)
{


+ 26
- 0
app/tests/project_management_tests.cpp 查看文件

@@ -934,6 +934,31 @@ void testServiceStateAndSaveErrors()
"failed save as must keep the previous current path");
}

void testLowerConfiguredLimitRejectsProjectAtomically()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
const QString path = directory.filePath("two-pages.json");

JsonProjectStorage default_storage;
require(default_storage.save(makeExampleProject(), path.toStdString()).succeeded,
"configured-limit fixture must be saved with default limits");

ProjectLimitSettings limits;
limits.maximumHmiPages = 1U;
JsonProjectStorage limited_storage(limits);
ProjectService service(limited_storage, limits);
const std::string original_id = service.project().metadata.id;
const ProjectOperationResult result = service.load(path.toStdString());
require(!result.succeeded
&& result.message.find("元素数量为 2,当前上限为 1")
!= std::string::npos,
"a project above the configured limit must report actual and maximum counts");
require(service.project().metadata.id == original_id
&& !service.hasCurrentFile(),
"a rejected project must not replace or partially modify current state");
}

void testRegisterCommentService()
{
JsonProjectStorage storage;
@@ -994,6 +1019,7 @@ int main()
testInvalidFiles();
testQuantityFileLimits();
testHmiPageJsonBoundaries();
testLowerConfiguredLimitRejectsProjectAtomically();
testServiceStateAndSaveErrors();
}
catch (const std::exception &error)


+ 2
- 0
app/tests/tests.pro 查看文件

@@ -4,6 +4,7 @@ CONFIG += ordered

SUBDIRS += \
domain \
application_settings \
alarm_service \
hmi_editor_service \
logic_editor_service \
@@ -16,6 +17,7 @@ SUBDIRS += \
plc_runtime

domain.file = domain_tests.pro
application_settings.file = application_settings_tests.pro
alarm_service.file = alarm_service_tests.pro
hmi_editor_service.file = hmi_editor_service_tests.pro
logic_editor_service.file = logic_editor_service_tests.pro


正在加载...
取消
保存