Ver código fonte

feat: 增加状态文本控件

main
suyu 3 semanas atrás
pai
commit
e6c1666fd4
29 arquivos alterados com 1160 adições e 36 exclusões
  1. +3
    -0
      app/integrated_platform.pro
  2. +10
    -0
      app/src/domain/hmi_control_registry.cpp
  3. +4
    -2
      app/src/domain/hmi_control_registry.h
  4. +155
    -2
      app/src/domain/hmi_model.cpp
  5. +42
    -0
      app/src/domain/hmi_model.h
  6. +1
    -0
      app/src/domain/project_limits.h
  7. +4
    -1
      app/src/domain/project_model.cpp
  8. +1
    -1
      app/src/domain/project_model.h
  9. +159
    -3
      app/src/infrastructure/json_project_storage.cpp
  10. +1
    -1
      app/src/infrastructure/json_project_storage.h
  11. +6
    -0
      app/src/services/hmi_editor_service.cpp
  12. +91
    -1
      app/src/services/hmi_runtime_service.cpp
  13. +8
    -0
      app/src/services/hmi_runtime_service.h
  14. +1
    -1
      app/src/services/project_service.cpp
  15. +2
    -1
      app/src/services/runtime_mode_service.cpp
  16. +33
    -0
      app/src/ui/hmi_editor_widget.cpp
  17. +4
    -0
      app/src/ui/main_window.cpp
  18. +16
    -1
      app/src/ui/main_window.ui
  19. +62
    -6
      app/src/ui/property_panel_controller.cpp
  20. +2
    -0
      app/src/ui/property_panel_controller.h
  21. +214
    -0
      app/src/ui/status_text_dialog.cpp
  22. +36
    -0
      app/src/ui/status_text_dialog.h
  23. +74
    -0
      app/src/ui/status_text_dialog.ui
  24. +7
    -0
      app/src/ui/toolbar_icon_factory.cpp
  25. +1
    -0
      app/src/ui/toolbar_icon_factory.h
  26. +92
    -1
      app/tests/domain_tests.cpp
  27. +49
    -0
      app/tests/hmi_editor_service_tests.cpp
  28. +66
    -12
      app/tests/project_management_tests.cpp
  29. +16
    -3
      app/tests/runtime_mode_service_tests.cpp

+ 3
- 0
app/integrated_platform.pro Ver arquivo

@@ -20,6 +20,7 @@ SOURCES += \
src/ui/logic_instruction_dialog.cpp \
src/ui/alarm_configuration_dialog.cpp \
src/ui/register_comment_dialog.cpp \
src/ui/status_text_dialog.cpp \
src/ui/plc_connection_dialog.cpp \
src/ui/free_monitor_widget.cpp \
src/ui/runtime_monitor_window.cpp \
@@ -69,6 +70,7 @@ HEADERS += \
src/ui/logic_instruction_dialog.h \
src/ui/alarm_configuration_dialog.h \
src/ui/register_comment_dialog.h \
src/ui/status_text_dialog.h \
src/ui/plc_connection_dialog.h \
src/ui/free_monitor_widget.h \
src/ui/runtime_monitor_window.h \
@@ -124,6 +126,7 @@ FORMS += \
src/ui/logic_instruction_dialog.ui \
src/ui/alarm_configuration_dialog.ui \
src/ui/register_comment_dialog.ui \
src/ui/status_text_dialog.ui \
src/ui/plc_connection_dialog.ui \
src/ui/free_monitor_widget.ui \
src/ui/runtime_monitor_window.ui \


+ 10
- 0
app/src/domain/hmi_control_registry.cpp Ver arquivo

@@ -52,6 +52,15 @@ constexpr std::array kControlDescriptors = {
HmiBindingKind::None,
HmiRuntimeValueKind::None,
false},
HmiControlDescriptor{HmiControlType::StatusText,
"statusText",
"状态文本",
"status-text",
"状态文本",
{0, 0, 140, 40},
HmiBindingKind::BitOrWord,
HmiRuntimeValueKind::BitOrWord,
true},
HmiControlDescriptor{HmiControlType::PageJump,
"pageJump",
"页面跳转",
@@ -117,6 +126,7 @@ std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind)
return RegisterArea::D;
}
case HmiBindingKind::None:
case HmiBindingKind::BitOrWord:
default:
{
return std::nullopt;


+ 4
- 2
app/src/domain/hmi_control_registry.h Ver arquivo

@@ -19,7 +19,8 @@ enum class HmiBindingKind
{
None, // 不支持寄存器绑定
Bit, // 绑定 M 区位地址
Word // 绑定 D 区字地址
Word, // 绑定 D 区字地址
BitOrWord // 根据控件配置绑定 M 位或 D 字
};

/**
@@ -29,7 +30,8 @@ enum class HmiRuntimeValueKind
{
None, // 运行态不读取寄存器值
Bit, // 运行态读取绑定的 M 位值
Word // 运行态读取绑定的 D 字值
Word, // 运行态读取绑定的 D 字值
BitOrWord // 运行态按绑定区域读取 M 位或 D 字
};

/**


+ 155
- 2
app/src/domain/hmi_model.cpp Ver arquivo

@@ -6,6 +6,7 @@
#include <algorithm>
#include <cctype>
#include <charconv>
#include <cmath>

namespace {

@@ -47,6 +48,101 @@ bool isBooleanValue(const std::string &value)
return value == "true" || value == "false";
}

bool isBlank(const std::string &value)
{
return value.empty()
|| std::all_of(
value.cbegin(), value.cend(),
[](unsigned char character)
{
return std::isspace(character) != 0;
});
}

bool validateStatusTextValue(
const std::string &value, const char *name, std::string *error)
{
if (isBlank(value))
{
setError(error, std::string(name) + "不能为空");
return false;
}
if (value.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, std::string(name) + "不能超过 256 个 UTF-8 字节");
return false;
}
if (value.find('\r') != std::string::npos
|| value.find('\n') != std::string::npos)
{
setError(error, std::string(name) + "只能使用单行文本");
return false;
}
return true;
}

bool validateStatusWordConfig(
const HmiStatusWordTextConfig &config,
RegisterDataType data_type,
std::string *error)
{
if (config.ranges.empty()
|| config.ranges.size() > ProjectLimits::kMaximumStatusTextRanges)
{
setError(error, "D 状态文本必须配置 1~16 个连续区间");
return false;
}
if (config.ranges.front().lowerBound.has_value()
|| config.ranges.back().upperBound.has_value())
{
setError(error, "D 状态文本的首个下限和最后一个上限必须为不限");
return false;
}
const bool integer_type = data_type == RegisterDataType::Int16
|| data_type == RegisterDataType::Int32;
for (std::size_t index = 0; index < config.ranges.size(); ++index)
{
const HmiStatusValueRange &range = config.ranges[index];
if (!validateStatusTextValue(range.text, "区间显示文本", error))
{
return false;
}
if ((range.lowerBound.has_value() && !std::isfinite(*range.lowerBound))
|| (range.upperBound.has_value() && !std::isfinite(*range.upperBound)))
{
setError(error, "D 状态文本区间边界必须是有限数值");
return false;
}
if (integer_type
&& ((range.lowerBound.has_value()
&& std::trunc(*range.lowerBound) != *range.lowerBound)
|| (range.upperBound.has_value()
&& std::trunc(*range.upperBound) != *range.upperBound)))
{
setError(error, "Int16/Int32 状态文本的区间边界必须是整数");
return false;
}
if (range.lowerBound.has_value() && range.upperBound.has_value()
&& *range.lowerBound >= *range.upperBound)
{
setError(error, "D 状态文本区间必须满足下限小于上限");
return false;
}
if (index > 0U)
{
const HmiStatusValueRange &previous = config.ranges[index - 1U];
if (!previous.upperBound.has_value()
|| !range.lowerBound.has_value()
|| *previous.upperBound != *range.lowerBound)
{
setError(error, "D 状态文本相邻区间必须首尾相接");
return false;
}
}
}
return true;
}

bool validateAppearanceProperty(
const std::string &key, const std::string &value, std::string *error)
{
@@ -148,7 +244,8 @@ bool HmiControl::validate(std::string *error) const
}
const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
if (binding.has_value() && !binding_area.has_value())
if (binding.has_value() && !binding_area.has_value()
&& descriptor->bindingKind != HmiBindingKind::BitOrWord)
{
setError(error, "该 HMI 控件不能绑定寄存器");
return false;
@@ -171,8 +268,11 @@ bool HmiControl::validate(std::string *error) const
setError(error, "HMI 控件绑定了无效地址");
return false;
}
const bool status_text = type == HmiControlType::StatusText;
const bool numeric = type == HmiControlType::NumericDisplay
|| type == HmiControlType::NumericInput;
|| type == HmiControlType::NumericInput
|| (status_text && statusText.has_value()
&& std::holds_alternative<HmiStatusWordTextConfig>(*statusText));
if (!registerDataTypeIsSupported(dataType))
{
setError(error, "HMI 数值类型无效");
@@ -199,6 +299,48 @@ bool HmiControl::validate(std::string *error) const
}
return false;
}
if (status_text)
{
if (!statusText.has_value())
{
setError(error, "状态文本控件缺少状态映射配置");
return false;
}
if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(&*statusText))
{
if (binding.has_value() && binding->area() != RegisterArea::M)
{
setError(error, "M 状态文本必须绑定 M 区地址");
return false;
}
if (dataType != RegisterDataType::Int16
|| !validateStatusTextValue(bit->offText, "OFF 显示文本", error)
|| !validateStatusTextValue(bit->onText, "ON 显示文本", error))
{
return false;
}
}
else
{
if (binding.has_value() && binding->area() != RegisterArea::D)
{
setError(error, "D 状态文本必须绑定 D 区地址");
return false;
}
if (!validateStatusWordConfig(
std::get<HmiStatusWordTextConfig>(*statusText),
dataType,
error))
{
return false;
}
}
}
else if (statusText.has_value())
{
setError(error, "非状态文本控件不能包含状态映射配置");
return false;
}
if (type == HmiControlType::PageJump)
{
if (!pageJump.has_value())
@@ -244,6 +386,17 @@ bool HmiControl::isConfigured() const
}
const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
if (type == HmiControlType::StatusText)
{
if (!statusText.has_value())
{
return false;
}
const RegisterArea expected = std::holds_alternative<
HmiStatusBitTextConfig>(*statusText)
? RegisterArea::M : RegisterArea::D;
return binding->area() == expected;
}
return binding_area.has_value() && binding->area() == *binding_area;
}



+ 42
- 0
app/src/domain/hmi_model.h Ver arquivo

@@ -14,6 +14,7 @@
#include <map>
#include <optional>
#include <string>
#include <variant>
#include <vector>

namespace HmiAppearanceProperty
@@ -54,6 +55,7 @@ enum class HmiControlType
NumericDisplay, // 数值显示
NumericInput, // 数值输入
Label, // 标签
StatusText, // 状态文本
PageJump, // 页面跳转
AlarmList, // 报警列表
Count // 已注册控件类型数量,不作为实际控件使用
@@ -75,6 +77,45 @@ struct HmiPageJumpConfig
std::string targetPageId;
};

struct HmiStatusBitTextConfig
{
std::string offText;
std::string onText;

bool operator==(const HmiStatusBitTextConfig &other) const
{
return offText == other.offText && onText == other.onText;
}
};

struct HmiStatusValueRange
{
std::optional<double> lowerBound;
std::optional<double> upperBound;
std::string text;

bool operator==(const HmiStatusValueRange &other) const
{
return lowerBound == other.lowerBound
&& upperBound == other.upperBound
&& text == other.text;
}
};

struct HmiStatusWordTextConfig
{
std::vector<HmiStatusValueRange> ranges;

bool operator==(const HmiStatusWordTextConfig &other) const
{
return ranges == other.ranges;
}
};

using HmiStatusTextConfig = std::variant<
HmiStatusBitTextConfig,
HmiStatusWordTextConfig>;

/**
* @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置
*
@@ -91,6 +132,7 @@ struct HmiControl
std::map<std::string, std::string> properties;
HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn;
std::optional<HmiPageJumpConfig> pageJump;
std::optional<HmiStatusTextConfig> statusText;

/**
* @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定


+ 1
- 0
app/src/domain/project_limits.h Ver arquivo

@@ -25,6 +25,7 @@ constexpr std::size_t kMaximumVerticalConnectionsPerLogic =
static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列

constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性
constexpr std::size_t kMaximumStatusTextRanges = 16U; // 一个状态文本控件最多配置 16 个连续数值区间
constexpr std::size_t kMaximumIdBytes = 128U; // ID 最多 128 个 UTF-8 字节,够用来做稳定标识
constexpr std::size_t kMaximumTextBytes = 256U; // 工程名称等普通文本最多 256 个 UTF-8 字节
constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符


+ 4
- 1
app/src/domain/project_model.cpp Ver arquivo

@@ -82,7 +82,10 @@ struct HmiDataBinding
bool isNumericControl(const HmiControl &control)
{
return control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput;
|| control.type == HmiControlType::NumericInput
|| (control.type == HmiControlType::StatusText
&& control.binding.has_value()
&& control.binding->area() == RegisterArea::D);
}

bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right)


+ 1
- 1
app/src/domain/project_model.h Ver arquivo

@@ -26,7 +26,7 @@ struct ProjectMetadata
// 工程显示名称
std::string name;
// 工程文件格式版本
std::string formatVersion = "2.0";
std::string formatVersion = "3.0";
};

// 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑


+ 159
- 3
app/src/infrastructure/json_project_storage.cpp Ver arquivo

@@ -18,7 +18,7 @@
namespace {

// 当前读写实现支持的工程文件格式版本
constexpr const char *kCurrentFormatVersion = "2.0";
constexpr const char *kCurrentFormatVersion = "3.0";

// 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因
struct ParseState
@@ -648,6 +648,134 @@ bool parseProperties(
return true;
}

QJsonValue serializeOptionalNumber(const std::optional<double> &value)
{
return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null);
}

QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config)
{
QJsonObject object;
if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(&config))
{
object.insert(QStringLiteral("source"), QStringLiteral("m"));
object.insert(QStringLiteral("offText"), fromUtf8(bit->offText));
object.insert(QStringLiteral("onText"), fromUtf8(bit->onText));
return object;
}
object.insert(QStringLiteral("source"), QStringLiteral("d"));
QJsonArray ranges;
for (const HmiStatusValueRange &range :
std::get<HmiStatusWordTextConfig>(config).ranges)
{
QJsonObject range_object;
range_object.insert(
QStringLiteral("lower"), serializeOptionalNumber(range.lowerBound));
range_object.insert(
QStringLiteral("upper"), serializeOptionalNumber(range.upperBound));
range_object.insert(QStringLiteral("text"), fromUtf8(range.text));
ranges.append(range_object);
}
object.insert(QStringLiteral("ranges"), ranges);
return object;
}

bool readNullableFiniteNumber(
const QJsonObject &object,
const char *field,
const std::string &context,
std::optional<double> *value,
ParseState *state)
{
QJsonValue json_value;
if (!readValue(object, field, context, &json_value, state))
{
return false;
}
if (json_value.isNull())
{
value->reset();
return true;
}
if (!json_value.isDouble() || !std::isfinite(json_value.toDouble()))
{
return state->fail(
ProjectStorageError::InvalidField,
fieldPath(context, field) + " 必须是有限数值或 null");
}
*value = json_value.toDouble();
return true;
}

bool parseStatusTextConfig(
const QJsonObject &object,
const std::string &context,
HmiStatusTextConfig *config,
ParseState *state)
{
std::string source;
if (!readString(object, "source", context, &source, state))
{
return false;
}
if (source == "m")
{
HmiStatusBitTextConfig bit;
if (!readString(object, "offText", context, &bit.offText, state)
|| !readString(object, "onText", context, &bit.onText, state))
{
return false;
}
*config = std::move(bit);
return true;
}
if (source != "d")
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".source 必须是 m 或 d");
}
QJsonArray range_array;
if (!readArray(object, "ranges", context, &range_array, state)
|| range_array.isEmpty()
|| range_array.size()
> static_cast<int>(ProjectLimits::kMaximumStatusTextRanges))
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".ranges 必须包含 1~16 个区间");
}
HmiStatusWordTextConfig word;
word.ranges.reserve(static_cast<std::size_t>(range_array.size()));
for (int index = 0; index < range_array.size(); ++index)
{
if (!range_array.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".ranges 的元素必须是对象");
}
const QJsonObject range_object = range_array.at(index).toObject();
const std::string range_context = context + ".ranges["
+ std::to_string(index) + ']';
HmiStatusValueRange range;
if (!readNullableFiniteNumber(
range_object, "lower", range_context,
&range.lowerBound, state)
|| !readNullableFiniteNumber(
range_object, "upper", range_context,
&range.upperBound, state)
|| !readString(
range_object, "text", range_context, &range.text, state))
{
return false;
}
word.ranges.push_back(std::move(range));
}
*config = std::move(word);
return true;
}

// 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象
QJsonObject serializeHmiControl(const HmiControl &control)
{
@@ -666,8 +794,12 @@ QJsonObject serializeHmiControl(const HmiControl &control)
object.insert(QStringLiteral("binding"), QJsonValue::Null);
}
object.insert(QStringLiteral("properties"), serializeProperties(control.properties));
const bool status_word = control.type == HmiControlType::StatusText
&& control.statusText.has_value()
&& std::holds_alternative<HmiStatusWordTextConfig>(*control.statusText);
if (control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput)
|| control.type == HmiControlType::NumericInput
|| status_word)
{
object.insert(
QStringLiteral("dataType"),
@@ -687,6 +819,14 @@ QJsonObject serializeHmiControl(const HmiControl &control)
? control.pageJump->targetPageId
: std::string{}));
}
if (control.type == HmiControlType::StatusText)
{
object.insert(
QStringLiteral("statusText"),
control.statusText.has_value()
? serializeStatusTextConfig(*control.statusText)
: QJsonObject{});
}
return object;
}

@@ -718,8 +858,24 @@ bool parseHmiControl(
{
return false;
}
if (control->type == HmiControlType::StatusText)
{
QJsonObject status_object;
HmiStatusTextConfig status_config = HmiStatusBitTextConfig{};
if (!readObject(object, "statusText", context, &status_object, state)
|| !parseStatusTextConfig(
status_object, context + ".statusText", &status_config, state))
{
return false;
}
control->statusText = std::move(status_config);
}
const bool status_word = control->type == HmiControlType::StatusText
&& control->statusText.has_value()
&& std::holds_alternative<HmiStatusWordTextConfig>(*control->statusText);
if (control->type == HmiControlType::NumericDisplay
|| control->type == HmiControlType::NumericInput)
|| control->type == HmiControlType::NumericInput
|| status_word)
{
std::string data_type_text;
if (!readString(object, "dataType", context, &data_type_text, state)


+ 1
- 1
app/src/infrastructure/json_project_storage.h Ver arquivo

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

#include "domain/project_storage.h"

// 严格读写当前 2.0 JSON 工程格式
// 严格读写当前 3.0 JSON 工程格式
class JsonProjectStorage final : public ProjectStorage
{
public:


+ 6
- 0
app/src/services/hmi_editor_service.cpp Ver arquivo

@@ -137,6 +137,7 @@ bool HmiEditorService::controlsEqual(
&& left.dataType == right.dataType
&& left.properties == right.properties
&& left.buttonOperation == right.buttonOperation
&& left.statusText == right.statusText
&& page_jump_equal;
}

@@ -824,6 +825,11 @@ HmiControl HmiEditorService::makeControl(
{
control.pageJump = HmiPageJumpConfig{};
}
if (descriptor.type == HmiControlType::StatusText)
{
control.statusText = HmiStatusBitTextConfig{
"设备停止", "设备运行"};
}
const int offset = static_cast<int>(page.controls.size()) * 16;
control.bounds.x = std::min(20 + offset, page.width - control.bounds.width);
control.bounds.y = std::min(20 + offset, page.height - control.bounds.height);


+ 91
- 1
app/src/services/hmi_runtime_service.cpp Ver arquivo

@@ -2,8 +2,10 @@

#include "domain/hmi_control_registry.h"

#include <algorithm>
#include <cmath>
#include <limits>
#include <type_traits>

namespace {

@@ -17,6 +19,16 @@ HmiRuntimeWriteResult writeFailure(HmiRuntimeError error)
return {false, error};
}

double numericValueAsDouble(const RegisterNumericValue &value)
{
return std::visit(
[](const auto &typed_value)
{
return static_cast<double>(typed_value);
},
value);
}

} // namespace

HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository)
@@ -43,7 +55,10 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
}
const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
if (!binding_area.has_value() || control.binding->area() != *binding_area)
const bool flexible_binding = descriptor->bindingKind
== HmiBindingKind::BitOrWord;
if ((!binding_area.has_value() && !flexible_binding)
|| (binding_area.has_value() && control.binding->area() != *binding_area))
{
return readFailure(HmiRuntimeError::InvalidBinding);
}
@@ -79,6 +94,34 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
true, HmiRuntimeError::None, false, *value}
: readFailure(HmiRuntimeError::RepositoryFailure);
}
case HmiRuntimeValueKind::BitOrWord:
{
if (control.binding->area() == RegisterArea::M)
{
const BitReadResult result = repository_.readBit(*control.binding);
return result.succeeded
? HmiRuntimeReadResult{
true, HmiRuntimeError::None, result.value, std::int16_t{0}}
: readFailure(repositoryError(result.error));
}
if (control.binding->area() != RegisterArea::D
|| !registerDataTypeAddressIsValid(control.dataType, *control.binding))
{
return readFailure(HmiRuntimeError::InvalidBinding);
}
const WordsReadResult result = repository_.readWords(
*control.binding, registerDataTypeWordCount(control.dataType));
if (!result.succeeded)
{
return readFailure(repositoryError(result.error));
}
const std::optional<RegisterNumericValue> value =
decodeRegisterNumericValue(control.dataType, result.values);
return value.has_value()
? HmiRuntimeReadResult{
true, HmiRuntimeError::None, false, *value}
: readFailure(HmiRuntimeError::RepositoryFailure);
}
case HmiRuntimeValueKind::None:
default:
{
@@ -87,6 +130,53 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
}
}

HmiStatusTextReadResult HmiRuntimeService::readStatusText(
const HmiControl &control) const
{
if (control.type != HmiControlType::StatusText
|| !control.statusText.has_value())
{
return {false, HmiRuntimeError::UnsupportedControl, {}};
}
const bool bit_config = std::holds_alternative<HmiStatusBitTextConfig>(
*control.statusText);
if (!control.binding.has_value()
|| control.binding->area()
!= (bit_config ? RegisterArea::M : RegisterArea::D))
{
return {false, HmiRuntimeError::InvalidBinding, {}};
}
const HmiRuntimeReadResult value = readControl(control);
if (!value.succeeded)
{
return {false, value.error, {}};
}
if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(
&*control.statusText))
{
return {true,
HmiRuntimeError::None,
value.bit_value ? bit->onText : bit->offText};
}
const double number = numericValueAsDouble(value.numeric_value);
const auto &ranges = std::get<HmiStatusWordTextConfig>(
*control.statusText).ranges;
const auto match = std::find_if(
ranges.cbegin(), ranges.cend(),
[number](const HmiStatusValueRange &range)
{
return (!range.lowerBound.has_value()
|| number >= *range.lowerBound)
&& (!range.upperBound.has_value()
|| number < *range.upperBound);
});
return match == ranges.cend()
? HmiStatusTextReadResult{
false, HmiRuntimeError::InvalidBinding, {}}
: HmiStatusTextReadResult{
true, HmiRuntimeError::None, match->text};
}

HmiRuntimeWriteResult HmiRuntimeService::operateButton(
const HmiControl &control, HmiButtonEvent event)
{


+ 8
- 0
app/src/services/hmi_runtime_service.h Ver arquivo

@@ -46,6 +46,13 @@ struct HmiRuntimeWriteResult
HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类
};

struct HmiStatusTextReadResult
{
bool succeeded = false;
HmiRuntimeError error = HmiRuntimeError::None;
std::string text;
};

/**
* @brief HMI 按钮在运行态产生的输入事件
*/
@@ -78,6 +85,7 @@ public:
* 控件未绑定、绑定区域错误或仓库不可用时不返回有效值
*/
HmiRuntimeReadResult readControl(const HmiControl &control) const;
HmiStatusTextReadResult readStatusText(const HmiControl &control) const;
/**
* @brief 根据按钮配置处理按下或释放事件
* @param control 按钮控件,必须绑定有效 M 地址


+ 1
- 1
app/src/services/project_service.cpp Ver arquivo

@@ -192,7 +192,7 @@ Project ProjectService::makeNewProject(const std::string &name)
project.metadata.id = generateProjectId();
project.metadata.name = name;
// 新工程固定使用当前存储格式版本
project.metadata.formatVersion = "2.0";
project.metadata.formatVersion = "3.0";
return project;
}



+ 2
- 1
app/src/services/runtime_mode_service.cpp Ver arquivo

@@ -323,7 +323,8 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
{
addresses.push_back(*control.binding);
if ((control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput)
|| control.type == HmiControlType::NumericInput
|| control.type == HmiControlType::StatusText)
&& control.binding->area() == RegisterArea::D)
{
const int word_count = registerDataTypeWordCount(control.dataType);


+ 33
- 0
app/src/ui/hmi_editor_widget.cpp Ver arquivo

@@ -276,6 +276,15 @@ public:
textWithValue());
break;
}
case HmiControlType::StatusText:
{
painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b"))));
painter->drawText(
rect.adjusted(4, 0, -4, 0),
Qt::AlignCenter | Qt::TextWordWrap,
textWithValue());
break;
}
case HmiControlType::PageJump:
{
const QColor fill = runtime_active_ && page_hovered_
@@ -503,6 +512,14 @@ public:
update();
}

void setStatusText(const std::string &text, bool available)
{
status_text_ = QString::fromUtf8(
text.data(), static_cast<int>(text.size()));
status_text_available_ = available;
update();
}

void setAlarmRecords(const std::vector<AlarmRecord> &records)
{
alarm_records_ = records;
@@ -834,6 +851,12 @@ private:
{
const QString text = QString::fromUtf8(
control_.text.data(), static_cast<int>(control_.text.size()));
if (control_.type == HmiControlType::StatusText)
{
return runtime_active_
? (status_text_available_ ? status_text_ : QStringLiteral("--"))
: QStringLiteral("状态文本");
}
if (!has_runtime_value_ || control_.type == HmiControlType::Button)
{
return text;
@@ -874,6 +897,8 @@ private:
RegisterNumericValue numeric_value_ = std::int16_t{0};
// 标记当前缓存值是否来自一次成功的运行时读取
bool has_runtime_value_ = false;
QString status_text_;
bool status_text_available_ = false;
};

// 将场景通用图元安全转换为本文件定义的 HMI 控件图元
@@ -935,6 +960,7 @@ void HmiEditorWidget::setRuntimeActive(bool active)
if (control_item != nullptr)
{
control_item->setRuntimeValue(false, std::int16_t{0}, false);
control_item->setStatusText({}, false);
control_item->setAlarmRecords({});
}
}
@@ -1082,6 +1108,13 @@ void HmiEditorWidget::refreshRuntimeValues()
control_item->setAlarmRecords(alarm_service_.records());
continue;
}
if (control->type == HmiControlType::StatusText)
{
const HmiStatusTextReadResult value =
runtime_service_.readStatusText(*control);
control_item->setStatusText(value.text, value.succeeded);
continue;
}
// 运行值通过服务读取,图元不直接接触寄存器仓库
const HmiRuntimeReadResult value = runtime_service_.readControl(*control);
control_item->setRuntimeValue(


+ 4
- 0
app/src/ui/main_window.cpp Ver arquivo

@@ -647,6 +647,8 @@ void MainWindow::configureActions()
[this] { addHmiControl(HmiControlType::NumericInput); });
connect(ui_->addLabelAction, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::Label); });
connect(ui_->addStatusTextAction, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::StatusText); });
connect(ui_->addPageJumpAction, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::PageJump); });
connect(ui_->addAlarmListAction, &QAction::triggered, this,
@@ -968,6 +970,7 @@ void MainWindow::configureAppearance()
ui_->addNumericDisplayAction->setIcon(makeUiIcon(UiIcon::NumericDisplay));
ui_->addNumericInputAction->setIcon(makeUiIcon(UiIcon::NumericInput));
ui_->addLabelAction->setIcon(makeUiIcon(UiIcon::Text));
ui_->addStatusTextAction->setIcon(makeUiIcon(UiIcon::StatusText));
ui_->addPageJumpAction->setIcon(makeUiIcon(UiIcon::PageJump));
ui_->addAlarmListAction->setIcon(makeUiIcon(UiIcon::AlarmList));
ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings));
@@ -2437,6 +2440,7 @@ void MainWindow::updateModeUi(const QString &message)
ui_->addNumericDisplayAction->setEnabled(policy.allowsProjectEditing);
ui_->addNumericInputAction->setEnabled(policy.allowsProjectEditing);
ui_->addLabelAction->setEnabled(policy.allowsProjectEditing);
ui_->addStatusTextAction->setEnabled(policy.allowsProjectEditing);
ui_->addPageJumpAction->setEnabled(policy.allowsProjectEditing);
ui_->addAlarmListAction->setEnabled(policy.allowsProjectEditing);
ui_->configureAlarmsAction->setEnabled(policy.allowsProjectEditing);


+ 16
- 1
app/src/ui/main_window.ui Ver arquivo

@@ -334,6 +334,7 @@
<addaction name="addNumericDisplayAction"/>
<addaction name="addNumericInputAction"/>
<addaction name="addLabelAction"/>
<addaction name="addStatusTextAction"/>
<addaction name="deleteControlAction"/>
</widget>
<widget class="QToolBar" name="logicToolBar">
@@ -785,7 +786,17 @@
<item><property name="text"><string>Double (Float64)</string></property></item>
</widget>
</item>
<item row="14" column="0" colspan="2">
<item row="14" column="0">
<widget class="QLabel" name="statusTextConfigLabel">
<property name="text"><string>状态映射</string></property>
</widget>
</item>
<item row="14" column="1">
<widget class="QPushButton" name="configureStatusTextButton">
<property name="text"><string>配置</string></property>
</widget>
</item>
<item row="15" column="0" colspan="2">
<widget class="QPushButton" name="applyPropertiesButton">
<property name="text">
<string>应用属性</string>
@@ -1123,6 +1134,10 @@
<string>添加页面跳转控件</string>
</property>
</action>
<action name="addStatusTextAction">
<property name="text"><string>状态文本</string></property>
<property name="toolTip"><string>添加状态文本控件</string></property>
</action>
<action name="addAlarmListAction">
<property name="text">
<string>报警列表</string>


+ 62
- 6
app/src/ui/property_panel_controller.cpp Ver arquivo

@@ -5,6 +5,7 @@
#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "logic_instruction_dialog.h"
#include "status_text_dialog.h"
#include "services/hmi_editor_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
@@ -128,6 +129,8 @@ void PropertyPanelController::configure()
&parent_, [this] { applySelectedLogicNodeProperties(); });
QObject::connect(ui_.textColorButton, &QPushButton::clicked,
&parent_, [this] { chooseTextColor(); });
QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked,
&parent_, [this] { configureStatusText(); });
ui_.targetPageLabel->setVisible(false);
ui_.targetPageComboBox->setVisible(false);
showControlProperties({});
@@ -204,6 +207,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
static_cast<QWidget *>(ui_.targetPageComboBox),
static_cast<QWidget *>(ui_.buttonOperationComboBox),
static_cast<QWidget *>(ui_.dataTypeComboBox),
static_cast<QWidget *>(ui_.configureStatusTextButton),
static_cast<QWidget *>(ui_.textColorEdit),
static_cast<QWidget *>(ui_.textColorButton),
static_cast<QWidget *>(ui_.fontSizeSpinBox),
@@ -228,6 +232,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.buttonOperationComboBox->setVisible(false);
ui_.dataTypeLabel->setVisible(false);
ui_.dataTypeComboBox->setVisible(false);
ui_.statusTextConfigLabel->setVisible(false);
ui_.configureStatusTextButton->setVisible(false);
ui_.textColorEdit->clear();
ui_.textColorButton->setStyleSheet(QString{});
ui_.fontSizeSpinBox->setValue(
@@ -242,8 +248,11 @@ void PropertyPanelController::showControlProperties(const std::string &control_i

ui_.controlTextEdit->setMaxLength(
static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters));
const bool is_status_text = control->type == HmiControlType::StatusText;
ui_.controlIdEdit->setText(fromUtf8(control->id));
ui_.controlTextEdit->setText(fromUtf8(control->text));
ui_.controlTextEdit->setVisible(!is_status_text);
ui_.controlTextLabel->setVisible(!is_status_text);
ui_.controlXSpinBox->setValue(control->bounds.x);
ui_.controlYSpinBox->setValue(control->bounds.y);
ui_.controlWidthSpinBox->setValue(control->bounds.width);
@@ -253,7 +262,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.bindingIndexSpinBox->setValue(
control->binding.has_value() ? control->binding->index() : 0);
const bool has_binding = descriptor != nullptr
&& descriptor->bindingKind != HmiBindingKind::None;
&& descriptor->bindingKind != HmiBindingKind::None
&& !is_status_text;
ui_.bindingAreaLabel->setVisible(has_binding);
ui_.bindingAreaComboBox->setVisible(has_binding);
ui_.bindingIndexLabel->setVisible(has_binding);
@@ -274,6 +284,21 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.dataTypeComboBox->setEnabled(is_numeric);
ui_.dataTypeComboBox->setCurrentIndex(
ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType)));
ui_.statusTextConfigLabel->setVisible(is_status_text);
ui_.configureStatusTextButton->setVisible(is_status_text);
ui_.configureStatusTextButton->setEnabled(is_status_text);
if (is_status_text)
{
const QString source = control->statusText.has_value()
&& std::holds_alternative<HmiStatusWordTextConfig>(*control->statusText)
? QStringLiteral("D") : QStringLiteral("M");
ui_.configureStatusTextButton->setText(
control->binding.has_value()
? QObject::tr("配置(%1%2)")
.arg(source)
.arg(control->binding->index())
: QObject::tr("配置"));
}

const bool is_page_jump = control->type == HmiControlType::PageJump;
ui_.targetPageLabel->setVisible(is_page_jump);
@@ -536,16 +561,20 @@ void PropertyPanelController::applySelectedControlProperties()
control.bounds.y = ui_.controlYSpinBox->value();
control.bounds.width = ui_.controlWidthSpinBox->value();
control.bounds.height = ui_.controlHeightSpinBox->value();
control.dataType = static_cast<RegisterDataType>(
ui_.dataTypeComboBox->currentData().toInt());
if (control.type != HmiControlType::StatusText)
{
control.dataType = static_cast<RegisterDataType>(
ui_.dataTypeComboBox->currentData().toInt());
}
const HmiControlDescriptor *descriptor =
findHmiControlDescriptor(control.type);
if (descriptor == nullptr
|| descriptor->bindingKind == HmiBindingKind::None)
if (control.type != HmiControlType::StatusText
&& (descriptor == nullptr
|| descriptor->bindingKind == HmiBindingKind::None))
{
control.binding.reset();
}
else
else if (control.type != HmiControlType::StatusText)
{
const int binding_area = ui_.bindingAreaComboBox->currentData().toInt();
if (binding_area < 0)
@@ -644,6 +673,33 @@ void PropertyPanelController::chooseTextColor()
}
}

void PropertyPanelController::configureStatusText()
{
const HmiControl *old_control = hmi_editor_service_.findControl(
current_page_id_(), selected_control_id_);
if (old_control == nullptr || old_control->type != HmiControlType::StatusText)
{
return;
}
StatusTextDialog dialog(*old_control, &parent_);
if (dialog.exec() != QDialog::Accepted)
{
return;
}
const HmiEditorResult result = hmi_editor_service_.updateControl(
current_page_id_(), selected_control_id_, dialog.control());
if (!result.succeeded)
{
reportFailure(QObject::tr("配置状态文本"), result.message);
return;
}
hmi_editor_widget_->reloadPage();
hmi_editor_widget_->selectControl(result.id);
showControlProperties(result.id);
refresh_project_ui_();
status_reporter_(QObject::tr("状态文本配置已更新"), 3000);
}

void PropertyPanelController::applySelectedLogicNodeProperties()
{
const LogicNode *node = logic_editor_service_.findNode(


+ 2
- 0
app/src/ui/property_panel_controller.h Ver arquivo

@@ -89,6 +89,8 @@ private:
void handleLogicEditorError(const QString &message) const;
/** 打开颜色选择器并写回属性输入框 */
void chooseTextColor();
/** 打开状态文本专用配置并原子更新控件 */
void configureStatusText();
/** 统一报告属性操作失败 */
void reportFailure(
const QString &action, const std::string &message) const;


+ 214
- 0
app/src/ui/status_text_dialog.cpp Ver arquivo

@@ -0,0 +1,214 @@
#include "status_text_dialog.h"

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

#include <QComboBox>
#include <QHeaderView>
#include <QLineEdit>
#include <QLocale>
#include <QMessageBox>
#include <QPushButton>
#include <QTableWidget>

#include <cmath>

namespace {

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

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

QString boundaryText(const std::optional<double> &value)
{
return value.has_value()
? QString::number(*value, 'g', 17) : QStringLiteral("不限");
}

} // namespace

StatusTextDialog::StatusTextDialog(const HmiControl &control, QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::StatusTextDialog>()),
control_(control)
{
ui_->setupUi(this);
ui_->sourceComboBox->setItemData(0, 0);
ui_->sourceComboBox->setItemData(1, 1);
ui_->dataTypeComboBox->setItemData(0, static_cast<int>(RegisterDataType::Int16));
ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Int32));
ui_->dataTypeComboBox->setItemData(2, static_cast<int>(RegisterDataType::Float32));
ui_->dataTypeComboBox->setItemData(3, static_cast<int>(RegisterDataType::Float64));
ui_->addressSpinBox->setRange(0, RegisterAddress::kMaximumIndex);
ui_->rangeTableWidget->horizontalHeader()->setStretchLastSection(true);
ui_->rangeTableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
ui_->rangeTableWidget->setSelectionMode(QAbstractItemView::SingleSelection);

connect(ui_->sourceComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged),
this,
&StatusTextDialog::updateSourcePage);
connect(ui_->addRangeButton, &QPushButton::clicked,
this, &StatusTextDialog::addRange);
connect(ui_->removeRangeButton, &QPushButton::clicked,
this, &StatusTextDialog::removeSelectedRange);

ui_->addressSpinBox->setValue(
control.binding.has_value() ? control.binding->index() : 0);
ui_->dataTypeComboBox->setCurrentIndex(
ui_->dataTypeComboBox->findData(static_cast<int>(control.dataType)));
ui_->offTextEdit->setText(QStringLiteral("设备停止"));
ui_->onTextEdit->setText(QStringLiteral("设备运行"));
if (control.statusText.has_value()
&& std::holds_alternative<HmiStatusWordTextConfig>(*control.statusText))
{
ui_->sourceComboBox->setCurrentIndex(1);
for (const HmiStatusValueRange &range :
std::get<HmiStatusWordTextConfig>(*control.statusText).ranges)
{
appendRangeRow(range);
}
}
else
{
ui_->sourceComboBox->setCurrentIndex(0);
const HmiStatusBitTextConfig bit = control.statusText.has_value()
? std::get<HmiStatusBitTextConfig>(*control.statusText)
: HmiStatusBitTextConfig{"设备停止", "设备运行"};
ui_->offTextEdit->setText(fromUtf8(bit.offText));
ui_->onTextEdit->setText(fromUtf8(bit.onText));
}
if (ui_->rangeTableWidget->rowCount() == 0)
{
appendRangeRow({std::nullopt, 30.0, "低温"});
appendRangeRow({30.0, 80.0, "温度正常"});
appendRangeRow({80.0, std::nullopt, "高温"});
}
updateSourcePage();
}

StatusTextDialog::~StatusTextDialog() = default;

const HmiControl &StatusTextDialog::control() const
{
return control_;
}

void StatusTextDialog::updateSourcePage()
{
const bool word = ui_->sourceComboBox->currentData().toInt() == 1;
ui_->configurationStack->setCurrentIndex(word ? 1 : 0);
ui_->dataTypeLabel->setVisible(word);
ui_->dataTypeComboBox->setVisible(word);
ui_->addressLabel->setText(word ? tr("绑定地址(D)") : tr("绑定地址(M)"));
}

void StatusTextDialog::appendRangeRow(const HmiStatusValueRange &range)
{
const int row = ui_->rangeTableWidget->rowCount();
ui_->rangeTableWidget->insertRow(row);
ui_->rangeTableWidget->setItem(
row, 0, new QTableWidgetItem(boundaryText(range.lowerBound)));
ui_->rangeTableWidget->setItem(
row, 1, new QTableWidgetItem(boundaryText(range.upperBound)));
ui_->rangeTableWidget->setItem(
row, 2, new QTableWidgetItem(fromUtf8(range.text)));
}

void StatusTextDialog::addRange()
{
if (ui_->rangeTableWidget->rowCount()
>= static_cast<int>(ProjectLimits::kMaximumStatusTextRanges))
{
return;
}
appendRangeRow({std::nullopt, std::nullopt, "状态"});
ui_->rangeTableWidget->setCurrentCell(
ui_->rangeTableWidget->rowCount() - 1, 0);
}

void StatusTextDialog::removeSelectedRange()
{
const int row = ui_->rangeTableWidget->currentRow();
if (row >= 0)
{
ui_->rangeTableWidget->removeRow(row);
}
}

bool StatusTextDialog::readOptionalBoundary(
int row, int column, std::optional<double> *value)
{
const QTableWidgetItem *item = ui_->rangeTableWidget->item(row, column);
const QString text = item == nullptr ? QString{} : item->text().trimmed();
if (text.isEmpty() || text == QStringLiteral("不限"))
{
value->reset();
return true;
}
bool converted = false;
const double number = QLocale::c().toDouble(text, &converted);
if (!converted || !std::isfinite(number))
{
QMessageBox::warning(
this,
tr("状态文本配置"),
tr("第 %1 行的区间边界必须是有限数值或“不限”").arg(row + 1));
return false;
}
*value = number;
return true;
}

void StatusTextDialog::accept()
{
const bool word = ui_->sourceComboBox->currentData().toInt() == 1;
control_.binding = RegisterAddress{
word ? RegisterArea::D : RegisterArea::M,
ui_->addressSpinBox->value()};
if (!word)
{
control_.dataType = RegisterDataType::Int16;
control_.statusText = HmiStatusBitTextConfig{
toUtf8(ui_->offTextEdit->text()),
toUtf8(ui_->onTextEdit->text())};
}
else
{
control_.dataType = static_cast<RegisterDataType>(
ui_->dataTypeComboBox->currentData().toInt());
HmiStatusWordTextConfig config;
config.ranges.reserve(
static_cast<std::size_t>(ui_->rangeTableWidget->rowCount()));
for (int row = 0; row < ui_->rangeTableWidget->rowCount(); ++row)
{
HmiStatusValueRange range;
if (!readOptionalBoundary(row, 0, &range.lowerBound)
|| !readOptionalBoundary(row, 1, &range.upperBound))
{
return;
}
const QTableWidgetItem *text_item = ui_->rangeTableWidget->item(row, 2);
range.text = toUtf8(
text_item == nullptr ? QString{} : text_item->text());
config.ranges.push_back(std::move(range));
}
control_.statusText = std::move(config);
}

std::string error;
if (!control_.validate(&error))
{
QMessageBox::warning(
this, tr("状态文本配置"), fromUtf8(error));
return;
}
QDialog::accept();
}

+ 36
- 0
app/src/ui/status_text_dialog.h Ver arquivo

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

#include "domain/hmi_model.h"

#include <QDialog>

#include <memory>

namespace Ui {
class StatusTextDialog;
}

class StatusTextDialog final : public QDialog
{
Q_OBJECT

public:
explicit StatusTextDialog(const HmiControl &control, QWidget *parent = nullptr);
~StatusTextDialog() override;

const HmiControl &control() const;

private slots:
void updateSourcePage();
void addRange();
void removeSelectedRange();
void accept() override;

private:
void appendRangeRow(const HmiStatusValueRange &range);
bool readOptionalBoundary(
int row, int column, std::optional<double> *value);

std::unique_ptr<Ui::StatusTextDialog> ui_;
HmiControl control_;
};

+ 74
- 0
app/src/ui/status_text_dialog.ui Ver arquivo

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>StatusTextDialog</class>
<widget class="QDialog" name="StatusTextDialog">
<property name="windowTitle"><string>状态文本配置</string></property>
<property name="minimumSize"><size><width>560</width><height>420</height></size></property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QFormLayout" name="sourceForm">
<item row="0" column="0"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源</string></property></widget></item>
<item row="0" column="1">
<widget class="QComboBox" name="sourceComboBox">
<item><property name="text"><string>M 位状态</string></property></item>
<item><property name="text"><string>D 数值区间</string></property></item>
</widget>
</item>
<item row="1" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>绑定地址</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="addressSpinBox"><property name="maximum"><number>4000</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>数值类型</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="dataTypeComboBox">
<item><property name="text"><string>Int16</string></property></item>
<item><property name="text"><string>Int32</string></property></item>
<item><property name="text"><string>Float32</string></property></item>
<item><property name="text"><string>Double (Float64)</string></property></item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QStackedWidget" name="configurationStack">
<widget class="QWidget" name="bitPage">
<layout class="QFormLayout" name="bitForm">
<item row="0" column="0"><widget class="QLabel" name="offTextLabel"><property name="text"><string>OFF 显示</string></property></widget></item>
<item row="0" column="1"><widget class="QLineEdit" name="offTextEdit"><property name="maxLength"><number>256</number></property></widget></item>
<item row="1" column="0"><widget class="QLabel" name="onTextLabel"><property name="text"><string>ON 显示</string></property></widget></item>
<item row="1" column="1"><widget class="QLineEdit" name="onTextEdit"><property name="maxLength"><number>256</number></property></widget></item>
</layout>
</widget>
<widget class="QWidget" name="wordPage">
<layout class="QVBoxLayout" name="wordLayout">
<item>
<widget class="QTableWidget" name="rangeTableWidget">
<property name="columnCount"><number>3</number></property>
<property name="rowCount"><number>0</number></property>
<column><property name="text"><string>下限(含)</string></property></column>
<column><property name="text"><string>上限(不含)</string></property></column>
<column><property name="text"><string>显示文本</string></property></column>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="rangeButtonLayout">
<item><spacer name="rangeButtonSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QPushButton" name="addRangeButton"><property name="text"><string>添加区间</string></property></widget></item>
<item><widget class="QPushButton" name="removeRangeButton"><property name="text"><string>删除区间</string></property></widget></item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>StatusTextDialog</receiver><slot>accept()</slot></connection>
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>StatusTextDialog</receiver><slot>reject()</slot></connection>
</connections>
</ui>

+ 7
- 0
app/src/ui/toolbar_icon_factory.cpp Ver arquivo

@@ -298,6 +298,13 @@ QPixmap renderIcon(UiIcon icon, int size)
drawCenteredText(painter, QRectF(7, 6, 10, 14), QStringLiteral("T"), 9.0);
break;
}
case UiIcon::StatusText:
{
painter.drawRoundedRect(QRectF(2, 5, 20, 14), 2, 2);
drawCenteredText(
painter, QRectF(3, 6, 18, 12), QStringLiteral("ON"), 6.0);
break;
}
case UiIcon::PageJump:
{
painter.drawRect(QRectF(3, 3, 14, 18));


+ 1
- 0
app/src/ui/toolbar_icon_factory.h Ver arquivo

@@ -31,6 +31,7 @@ enum class UiIcon
NumericDisplay, // HMI 数值显示
NumericInput, // HMI 数值输入
Text, // HMI 文本
StatusText, // HMI 状态文本
PageJump, // HMI 页面跳转
AlarmList, // HMI 报警列表
AlarmSettings, // 报警配置


+ 92
- 1
app/tests/domain_tests.cpp Ver arquivo

@@ -255,6 +255,13 @@ void testHmiControlRegistryCompleteness()
"word runtime controls must bind to the D area");
break;
}
case HmiRuntimeValueKind::BitOrWord:
{
require(descriptor->bindingKind == HmiBindingKind::BitOrWord
&& !binding_area.has_value(),
"flexible runtime controls must choose M or D from their config");
break;
}
case HmiRuntimeValueKind::None:
{
require(descriptor->bindingKind == HmiBindingKind::None
@@ -286,6 +293,89 @@ void testHmiControlRegistryCompleteness()

Project makeValidProject();

void testStatusTextDomainRules()
{
HmiControl status;
status.id = "status";
status.type = HmiControlType::StatusText;
status.text = "Status";
status.binding = RegisterAddress{RegisterArea::M, 0};
status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"};
require(status.validate(), "M status text with OFF and ON labels must be valid");

status.binding = RegisterAddress{RegisterArea::D, 0};
require(!status.validate(), "M status text configuration must reject a D binding");

status.binding = RegisterAddress{RegisterArea::D, 20};
status.dataType = RegisterDataType::Float32;
status.statusText = HmiStatusWordTextConfig{{
{std::nullopt, 30.0, "Low"},
{30.0, 80.0, "Normal"},
{80.0, std::nullopt, "High"}}};
require(status.validate(),
"D status text must accept complete adjacent half-open ranges");

status.binding.reset();
require(status.validate(),
"an unbound D status text draft must keep its selected data type");
status.binding = RegisterAddress{RegisterArea::D, 20};

const auto consecutive_ranges = [](std::size_t count)
{
std::vector<HmiStatusValueRange> result;
result.reserve(count);
for (std::size_t index = 0; index < count; ++index)
{
result.push_back({
index == 0U
? std::optional<double>{}
: std::optional<double>{static_cast<double>(index)},
index + 1U == count
? std::optional<double>{}
: std::optional<double>{static_cast<double>(index + 1U)},
"State"});
}
return result;
};
status.statusText = HmiStatusWordTextConfig{
consecutive_ranges(ProjectLimits::kMaximumStatusTextRanges)};
require(status.validate(), "a status text control must accept 16 ranges");
status.statusText = HmiStatusWordTextConfig{
consecutive_ranges(ProjectLimits::kMaximumStatusTextRanges + 1U)};
require(!status.validate(), "a status text control must reject a 17th range");
status.statusText = HmiStatusWordTextConfig{{
{std::nullopt, 30.0, "Low"},
{30.0, 80.0, "Normal"},
{80.0, std::nullopt, "High"}}};

auto &ranges = std::get<HmiStatusWordTextConfig>(*status.statusText).ranges;
ranges[1].lowerBound = 31.0;
require(!status.validate(), "D status text must reject range gaps");
ranges[1].lowerBound = 29.0;
require(!status.validate(), "D status text must reject overlapping ranges");
ranges[1].lowerBound = 30.0;
ranges.front().lowerBound = -100.0;
require(!status.validate(), "D status text must start with an unlimited lower bound");
ranges.front().lowerBound.reset();
status.dataType = RegisterDataType::Int32;
ranges[1].upperBound = 80.5;
require(!status.validate(), "integer status text boundaries must be integers");

Project project = makeValidProject();
status.dataType = RegisterDataType::Float64;
status.binding = RegisterAddress{RegisterArea::D, 100};
ranges[1].upperBound = 80.0;
project.hmiPages.front().controls.push_back(status);
HmiControl overlapping;
overlapping.id = "overlapping";
overlapping.type = HmiControlType::NumericDisplay;
overlapping.text = "Overlap";
overlapping.binding = RegisterAddress{RegisterArea::D, 102};
project.hmiPages.front().controls.push_back(overlapping);
require(!project.validate(defaultProjectLimitSettings()),
"D status text must participate in multi-word HMI overlap checks");
}

void testMultiWordHmiBoundaries()
{
Project project = makeValidProject();
@@ -457,7 +547,7 @@ Project makeValidProject()
logic.rungs.push_back(rung);

Project project;
project.metadata = {"sample-project", "Sample project", "2.0"};
project.metadata = {"sample-project", "Sample project", "3.0"};
project.hmiPages.push_back(page);
project.initialHmiPageId = page.id;
project.controlLogics.push_back(logic);
@@ -983,6 +1073,7 @@ int main()
testRegisterRepositorySeparatesAreas();
testMultiWordCodecsAndBlockAccess();
testHmiControlRegistryCompleteness();
testStatusTextDomainRules();
testMultiWordHmiBoundaries();
testHmiAppearancePropertyBoundaries();
testLogicNodeConfigurationBoundaries();


+ 49
- 0
app/tests/hmi_editor_service_tests.cpp Ver arquivo

@@ -187,6 +187,54 @@ void testRuntimeUsesRegisterRepository()

}

void testStatusTextRuntimeMapping()
{
VirtualRegisterRepository repository;
HmiRuntimeService runtime(repository);
HmiControl status;
status.id = "machine-status";
status.type = HmiControlType::StatusText;
status.text = "Status";
status.binding = RegisterAddress{RegisterArea::M, 5};
status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"};

require(runtime.readStatusText(status).text == "Stopped",
"an OFF M bit must resolve to the configured OFF text");
repository.writeBit(*status.binding, true);
require(runtime.readStatusText(status).text == "Running",
"an ON M bit must resolve to the configured ON text");
require(runtime.writeNumericInput(status, 1).error
== HmiRuntimeError::UnsupportedControl,
"status text must remain read-only");

status.binding = RegisterAddress{RegisterArea::D, 20};
status.dataType = RegisterDataType::Float32;
status.statusText = HmiStatusWordTextConfig{{
{std::nullopt, 30.0, "Low"},
{30.0, 80.0, "Normal"},
{80.0, std::nullopt, "High"}}};
const auto write_float = [&repository, &status](float value)
{
const auto words = Float32Codec::encode(value);
repository.writeWords(*status.binding, {words[0], words[1]});
};
write_float(29.5f);
require(runtime.readStatusText(status).text == "Low",
"values below the first upper bound must resolve to the first text");
write_float(30.0f);
require(runtime.readStatusText(status).text == "Normal",
"a shared boundary must belong to the range starting at that boundary");
write_float(80.0f);
require(runtime.readStatusText(status).text == "High",
"the final boundary must belong to the unlimited upper range");

repository.writeWords(
*status.binding,
{static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7fc0)});
require(!runtime.readStatusText(status).succeeded,
"NaN register bits must make status text unavailable");
}

void testHistoryAndAtomicBatchDelete()
{
TestProjectStorage storage;
@@ -545,6 +593,7 @@ int main()
testRuntimeValidationAndPasteBoundaries();
testAppearanceEditing();
testRuntimeUsesRegisterRepository();
testStatusTextRuntimeMapping();
testPageLifecycleAndNavigation();
testPageResizeIsAtomicAndUndoable();
testConfiguredPageDefaultsAndLimits();


+ 66
- 12
app/tests/project_management_tests.cpp Ver arquivo

@@ -75,7 +75,7 @@ LadderRung makeRung(
Project makeExampleProject()
{
Project project;
project.metadata = {"example-project", "Example project", "2.0"};
project.metadata = {"example-project", "Example project", "3.0"};

HmiPage page;
page.id = "main-page";
@@ -168,7 +168,7 @@ void testEmptyProjectRoundTrip()
ProjectService service(storage, defaultProjectLimitSettings());
require(service.createNewProject("Empty project").succeeded,
"empty project creation must succeed");
require(service.project().metadata.formatVersion == "2.0",
require(service.project().metadata.formatVersion == "3.0",
"new projects must use the strict grid format");

const QString path = directory.filePath("empty.json");
@@ -193,15 +193,15 @@ void testGridProjectRoundTrip()
"the grid project must save");

const QByteArray json = readBytes(path);
require(json.contains("\"formatVersion\": \"2.0\"")
require(json.contains("\"formatVersion\": \"3.0\"")
&& json.contains("\"cells\"")
&& json.contains("\"verticalConnections\"")
&& json.contains("\"kind\": \"node\"")
&& json.contains("\"kind\": \"wire\""),
"2.0 JSON must persist cells and independent vertical edges");
"3.0 JSON must persist cells and independent vertical edges");
require(!json.contains("\"condition\"")
&& !json.contains("\"children\""),
"2.0 JSON must not serialize the removed expression tree");
"3.0 JSON must not serialize the removed expression tree");

const ProjectLoadResult loaded = storage.load(path.toStdString());
require(loaded.succeeded, "the grid project must load");
@@ -278,12 +278,12 @@ void testMultiWordHmiDataTypeRoundTrip()
project.hmiPages.front().controls.push_back(double_input);

require(storage.save(project, path.toStdString()).succeeded,
"Int32 and Double HMI controls must save in a 2.0 project");
"Int32 and Double HMI controls must save in a 3.0 project");
const QByteArray json = readBytes(path);
require(json.contains("\"dataType\": \"int32\"")
&& json.contains("\"dataType\": \"float64\"")
&& json.contains("\"formatVersion\": \"2.0\""),
"multi-word HMI types must use stable JSON names without a version bump");
&& json.contains("\"formatVersion\": \"3.0\""),
"multi-word HMI types must use stable JSON names in 3.0");

const ProjectLoadResult loaded = storage.load(path.toStdString());
require(loaded.succeeded
@@ -314,6 +314,59 @@ void testMultiWordHmiDataTypeRoundTrip()
"unknown HMI data types must be rejected by strict JSON loading");
}

void testStatusTextRoundTrip()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");
JsonProjectStorage storage(defaultProjectLimitSettings());
const QString path = directory.filePath("status-text.json");
Project project = makeExampleProject();

HmiControl bit_status;
bit_status.id = "bit-status";
bit_status.type = HmiControlType::StatusText;
bit_status.bounds = {20, 80, 140, 40};
bit_status.text = "Status";
bit_status.binding = RegisterAddress{RegisterArea::M, 10};
bit_status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"};
project.hmiPages.front().controls.push_back(bit_status);

HmiControl word_status = bit_status;
word_status.id = "word-status";
word_status.bounds.x = 180;
word_status.binding = RegisterAddress{RegisterArea::D, 100};
word_status.dataType = RegisterDataType::Float64;
word_status.statusText = HmiStatusWordTextConfig{{
{std::nullopt, 30.0, "Low"},
{30.0, 80.0, "Normal"},
{80.0, std::nullopt, "High"}}};
project.hmiPages.front().controls.push_back(word_status);

require(storage.save(project, path.toStdString()).succeeded,
"M and D status text controls must save in 3.0");
const QByteArray json = readBytes(path);
require(json.contains("\"type\": \"statusText\"")
&& json.contains("\"source\": \"m\"")
&& json.contains("\"source\": \"d\"")
&& json.contains("\"lower\": null")
&& json.contains("\"upper\": null")
&& json.contains("\"dataType\": \"float64\""),
"3.0 JSON must store explicit status text modes and range bounds");

const ProjectLoadResult loaded = storage.load(path.toStdString());
require(loaded.succeeded
&& loaded.project.hmiPages.front().controls.size() == 3U,
"status text controls must load after a JSON round trip");
const HmiControl &loaded_bit = loaded.project.hmiPages.front().controls[1];
const HmiControl &loaded_word = loaded.project.hmiPages.front().controls[2];
require(std::get<HmiStatusBitTextConfig>(*loaded_bit.statusText).onText
== "Running"
&& std::get<HmiStatusWordTextConfig>(*loaded_word.statusText)
.ranges[1].lowerBound == 30.0
&& loaded_word.dataType == RegisterDataType::Float64,
"status text labels, ranges and D type must survive round trip");
}

void testStrictVersionAndRequiredFields()
{
QTemporaryDir directory;
@@ -323,12 +376,12 @@ void testStrictVersionAndRequiredFields()
QJsonObject root = savedFixture(
storage, makeExampleProject(), fixture_path);

root.insert(QStringLiteral("formatVersion"), QStringLiteral("1.0"));
root.insert(QStringLiteral("formatVersion"), QStringLiteral("2.0"));
const QString old_path = directory.filePath("old.json");
writeBytes(old_path, QJsonDocument(root).toJson());
require(storage.load(old_path.toStdString()).error
== ProjectStorageError::UnsupportedVersion,
"old 1.0 files must be rejected without migration code");
"old 2.0 files must be rejected without migration code");

root = QJsonDocument::fromJson(readBytes(fixture_path)).object();
QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray();
@@ -340,7 +393,7 @@ void testStrictVersionAndRequiredFields()
writeBytes(missing_path, QJsonDocument(root).toJson());
require(storage.load(missing_path.toStdString()).error
== ProjectStorageError::MissingField,
"2.0 must require the vertical connection array explicitly");
"3.0 must require the vertical connection array explicitly");
}

void testInvalidGridAndConnectionsAreRejected()
@@ -410,7 +463,7 @@ void testInvalidGridAndConnectionsAreRejected()
writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson());
require(storage.load(hidden_comment_path.toStdString()).error
== ProjectStorageError::InvalidProject,
"a 2.0 project must reject comments stored on connected branch rows");
"a 3.0 project must reject comments stored on connected branch rows");

QJsonObject non_adjacent = original;
logic = firstLogic(&non_adjacent);
@@ -500,6 +553,7 @@ int main()
testGridProjectRoundTrip();
testMOffAlarmRoundTrip();
testMultiWordHmiDataTypeRoundTrip();
testStatusTextRoundTrip();
testStrictVersionAndRequiredFields();
testInvalidGridAndConnectionsAreRejected();
testProjectServiceStateAndConfiguredLimits();


+ 16
- 3
app/tests/runtime_mode_service_tests.cpp Ver arquivo

@@ -171,6 +171,16 @@ void testModeTransitions()
double_display.binding = RegisterAddress{RegisterArea::D, 60};
double_display.dataType = RegisterDataType::Float64;
page.controls.push_back(double_display);
HmiControl double_status = int32_display;
double_status.id = "runtime-double-status";
double_status.type = HmiControlType::StatusText;
double_status.bounds = {240, 0, 140, 40};
double_status.binding = RegisterAddress{RegisterArea::D, 70};
double_status.dataType = RegisterDataType::Float64;
double_status.statusText = HmiStatusWordTextConfig{{
{std::nullopt, 0.0, "Low"},
{0.0, std::nullopt, "High"}}};
page.controls.push_back(double_status);
project.initialHmiPageId = page.id;
project.hmiPages.push_back(page);
project.alarmDefinitions.push_back(
@@ -291,12 +301,15 @@ void testModeTransitions()
{RegisterArea::D, 40}, {RegisterArea::D, 41},
{RegisterArea::D, 50}, {RegisterArea::D, 51},
{RegisterArea::D, 60}, {RegisterArea::D, 61},
{RegisterArea::D, 62}, {RegisterArea::D, 63}})
{RegisterArea::D, 62}, {RegisterArea::D, 63},
{RegisterArea::D, 70}, {RegisterArea::D, 71},
{RegisterArea::D, 72}, {RegisterArea::D, 73}})
&& gateway.pollRanges()
== std::vector<RegisterWordRange>({
{{RegisterArea::D, 50}, 2},
{{RegisterArea::D, 60}, 4}}),
"all instruction M/D references must be polled while comments stay metadata-only");
{{RegisterArea::D, 60}, 4},
{{RegisterArea::D, 70}, 4}}),
"instructions and multi-word status text must be fully polled while comments stay metadata-only");
gateway.completeInitialRead();
require(service.initialPlcReadCompleted(),
"service must retain the initial PLC read state");


Carregando…
Cancelar
Salvar