| @@ -101,6 +101,11 @@ bool LogicNode::validate(std::string *error) const | |||||
| config); | config); | ||||
| } | } | ||||
| bool LogicNode::isConfigured() const | |||||
| { | |||||
| return configured; | |||||
| } | |||||
| bool LogicNode::isCondition() const | bool LogicNode::isCondition() const | ||||
| { | { | ||||
| return !std::holds_alternative<CoilNodeConfig>(config); | return !std::holds_alternative<CoilNodeConfig>(config); | ||||
| @@ -138,7 +143,29 @@ bool LadderStage::validate(std::string *error) const | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool LadderStage::validateForRunning(std::string *error) const | |||||
| { | |||||
| if (!validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| for (const LogicNode &node : branches) | |||||
| { | |||||
| if (!node.isConfigured()) | |||||
| { | |||||
| setError(error, "ladder condition " + node.id + " is not configured"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool LadderRung::validate(std::string *error) const | bool LadderRung::validate(std::string *error) const | ||||
| { | |||||
| return validateStructure(error); | |||||
| } | |||||
| bool LadderRung::validateForRunning(std::string *error) const | |||||
| { | { | ||||
| if (!validateStructure(error)) | if (!validateStructure(error)) | ||||
| { | { | ||||
| @@ -150,7 +177,19 @@ bool LadderRung::validate(std::string *error) const | |||||
| } | } | ||||
| if (stages.empty() || !output.has_value()) | if (stages.empty() || !output.has_value()) | ||||
| { | { | ||||
| setError(error, "non-empty ladder rung requires conditions and an output coil"); | |||||
| setError(error, "incomplete ladder network requires conditions and an output coil"); | |||||
| return false; | |||||
| } | |||||
| for (const LadderStage &stage : stages) | |||||
| { | |||||
| if (!stage.validateForRunning(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (!output->isConfigured()) | |||||
| { | |||||
| setError(error, "output coil " + output->id + " is not configured"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -257,3 +296,19 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| bool ControlLogic::validateForRunning(std::string *error) const | |||||
| { | |||||
| if (!validateStructure(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| for (const LadderRung &rung : rungs) | |||||
| { | |||||
| if (!rung.validateForRunning(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| @@ -56,8 +56,10 @@ struct LogicNode | |||||
| { | { | ||||
| std::string id; | std::string id; | ||||
| LogicNodeConfig config; | LogicNodeConfig config; | ||||
| bool configured = true; | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool isConfigured() const; | |||||
| bool isCondition() const; | bool isCondition() const; | ||||
| bool isOutput() const; | bool isOutput() const; | ||||
| }; | }; | ||||
| @@ -69,6 +71,7 @@ struct LadderStage | |||||
| std::vector<LogicNode> branches; | std::vector<LogicNode> branches; | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| // 梯级中的各级按顺序串联,输出线圈固定在最右侧 | // 梯级中的各级按顺序串联,输出线圈固定在最右侧 | ||||
| @@ -81,6 +84,7 @@ struct LadderRung | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| struct ControlLogic | struct ControlLogic | ||||
| @@ -92,4 +96,5 @@ struct ControlLogic | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| @@ -49,7 +49,7 @@ bool HmiControl::validate(std::string *error) const | |||||
| } | } | ||||
| if (requiresBitBinding(type)) | if (requiresBitBinding(type)) | ||||
| { | { | ||||
| if (!binding.has_value() || binding->area() != RegisterArea::M) | |||||
| if (binding.has_value() && binding->area() != RegisterArea::M) | |||||
| { | { | ||||
| setError(error, "button and indicator controls require an M address"); | setError(error, "button and indicator controls require an M address"); | ||||
| return false; | return false; | ||||
| @@ -57,7 +57,7 @@ bool HmiControl::validate(std::string *error) const | |||||
| } | } | ||||
| if (requiresWordBinding(type)) | if (requiresWordBinding(type)) | ||||
| { | { | ||||
| if (!binding.has_value() || binding->area() != RegisterArea::D) | |||||
| if (binding.has_value() && binding->area() != RegisterArea::D) | |||||
| { | { | ||||
| setError(error, "numeric controls require a D address"); | setError(error, "numeric controls require a D address"); | ||||
| return false; | return false; | ||||
| @@ -71,6 +71,23 @@ bool HmiControl::validate(std::string *error) const | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool HmiControl::isConfigured() const | |||||
| { | |||||
| if (type == HmiControlType::Label) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| if (!binding.has_value() || !binding->isValid()) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (requiresBitBinding(type)) | |||||
| { | |||||
| return binding->area() == RegisterArea::M; | |||||
| } | |||||
| return !requiresWordBinding(type) || binding->area() == RegisterArea::D; | |||||
| } | |||||
| bool HmiPage::validate(std::string *error) const | bool HmiPage::validate(std::string *error) const | ||||
| { | { | ||||
| if (id.empty() || name.empty()) | if (id.empty() || name.empty()) | ||||
| @@ -109,3 +126,20 @@ bool HmiPage::validate(std::string *error) const | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| bool HmiPage::validateForRunning(std::string *error) const | |||||
| { | |||||
| if (!validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| for (const HmiControl &control : controls) | |||||
| { | |||||
| if (!control.isConfigured()) | |||||
| { | |||||
| setError(error, "HMI control " + control.id + " is not bound to a register"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| @@ -66,6 +66,7 @@ struct HmiControl | |||||
| * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 | * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 | ||||
| */ | */ | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool isConfigured() const; | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -87,4 +88,5 @@ struct HmiPage | |||||
| * @return 页面及其全部控件有效时返回 true | * @return 页面及其全部控件有效时返回 true | ||||
| */ | */ | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| @@ -69,3 +69,26 @@ bool Project::validate(std::string *error) const | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| bool Project::validateForRunning(std::string *error) const | |||||
| { | |||||
| if (!validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| for (const HmiPage &page : hmiPages) | |||||
| { | |||||
| if (!page.validateForRunning(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : controlLogics) | |||||
| { | |||||
| if (!logic.validateForRunning(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| @@ -29,4 +29,5 @@ struct Project | |||||
| // 校验工程配置并通过 error 返回失败原因 | // 校验工程配置并通过 error 返回失败原因 | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| @@ -74,7 +74,8 @@ enum class ModeTransitionError | |||||
| None, // 切换成功 | None, // 切换成功 | ||||
| AlreadyInRequestedMode, // 已经处于目标模式 | AlreadyInRequestedMode, // 已经处于目标模式 | ||||
| MustReturnToEditing, // 必须先返回编辑态 | MustReturnToEditing, // 必须先返回编辑态 | ||||
| InitialPlcReadRequired // 进入真机态前必须完成 PLC 初次读取 | |||||
| InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | |||||
| ProjectNotReady // 工程存在未配置或未完成的运行项 | |||||
| }; | }; | ||||
| // 模式切换操作结果 | // 模式切换操作结果 | ||||
| @@ -719,6 +719,7 @@ QJsonObject serializeLogicNode(const LogicNode &node) | |||||
| { | { | ||||
| QJsonObject object; | QJsonObject object; | ||||
| object.insert(QStringLiteral("id"), fromUtf8(node.id)); | object.insert(QStringLiteral("id"), fromUtf8(node.id)); | ||||
| object.insert(QStringLiteral("configured"), node.configured); | |||||
| // std::visit 将不同节点配置统一转换为 config JSON 对象 | // std::visit 将不同节点配置统一转换为 config JSON 对象 | ||||
| object.insert( | object.insert( | ||||
| QStringLiteral("config"), | QStringLiteral("config"), | ||||
| @@ -820,6 +821,13 @@ bool parseLogicNode( | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| // 1.0 旧工程没有 configured 字段,按历史默认地址视为已配置 | |||||
| node->configured = true; | |||||
| if (object.contains(QStringLiteral("configured")) | |||||
| && !readBool(object, "configured", context, &node->configured, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -30,7 +30,7 @@ int main(int argc, char *argv[]) | |||||
| // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | ||||
| VirtualRegisterRepository virtual_register_repository; | VirtualRegisterRepository virtual_register_repository; | ||||
| HmiRuntimeService hmi_runtime_service(virtual_register_repository); | HmiRuntimeService hmi_runtime_service(virtual_register_repository); | ||||
| RuntimeModeService runtime_mode_service; | |||||
| RuntimeModeService runtime_mode_service(project_service); | |||||
| MainWindow main_window( | MainWindow main_window( | ||||
| runtime_mode_service, | runtime_mode_service, | ||||
| project_service, | project_service, | ||||
| @@ -14,6 +14,7 @@ LogicNode makeNode(const std::string &id, const LogicNodeConfig &config) | |||||
| LogicNode node; | LogicNode node; | ||||
| node.id = id; | node.id = id; | ||||
| node.config = config; | node.config = config; | ||||
| node.configured = false; | |||||
| return node; | return node; | ||||
| } | } | ||||
| @@ -367,6 +368,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| "node category cannot be changed after creation"); | "node category cannot be changed after creation"); | ||||
| } | } | ||||
| LogicNode candidate = makeNode(node_id, config); | LogicNode candidate = makeNode(node_id, config); | ||||
| candidate.configured = true; | |||||
| std::string error; | std::string error; | ||||
| if (!candidate.validate(&error)) | if (!candidate.validate(&error)) | ||||
| { | { | ||||
| @@ -388,6 +390,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| if (editable.id == node_id) | if (editable.id == node_id) | ||||
| { | { | ||||
| editable.config = config; | editable.config = config; | ||||
| editable.configured = true; | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| } | } | ||||
| @@ -395,6 +398,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| if (rung.output.has_value() && rung.output->id == node_id) | if (rung.output.has_value() && rung.output->id == node_id) | ||||
| { | { | ||||
| rung.output->config = config; | rung.output->config = config; | ||||
| rung.output->configured = true; | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| } | } | ||||
| @@ -8,6 +8,13 @@ | |||||
| #include "runtime_mode_service.h" | #include "runtime_mode_service.h" | ||||
| #include "project_service.h" | |||||
| RuntimeModeService::RuntimeModeService(const ProjectService &project_service) | |||||
| : project_service_(project_service) | |||||
| { | |||||
| } | |||||
| ApplicationMode RuntimeModeService::mode() const | ApplicationMode RuntimeModeService::mode() const | ||||
| { | { | ||||
| return state_.mode(); | return state_.mode(); | ||||
| @@ -25,6 +32,11 @@ ModeTransitionResult RuntimeModeService::enterEditing() | |||||
| ModeTransitionResult RuntimeModeService::enterOfflineRunning() | ModeTransitionResult RuntimeModeService::enterOfflineRunning() | ||||
| { | { | ||||
| std::string error; | |||||
| if (!project_service_.project().validateForRunning(&error)) | |||||
| { | |||||
| return {false, ModeTransitionError::ProjectNotReady}; | |||||
| } | |||||
| return state_.enterOfflineRunning(); | return state_.enterOfflineRunning(); | ||||
| } | } | ||||
| @@ -10,14 +10,14 @@ | |||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| class ProjectService; | |||||
| // 隔离 UI 与领域状态机并编排进入真机运行态的前置条件 | // 隔离 UI 与领域状态机并编排进入真机运行态的前置条件 | ||||
| class RuntimeModeService | class RuntimeModeService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 获取当前应用运行模式 | |||||
| * @return 当前的编辑、离线运行或真机运行模式 | |||||
| */ | |||||
| explicit RuntimeModeService(const ProjectService &project_service); | |||||
| ApplicationMode mode() const; | ApplicationMode mode() const; | ||||
| /** | /** | ||||
| * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | ||||
| @@ -55,6 +55,7 @@ public: | |||||
| bool initialPlcReadCompleted() const; | bool initialPlcReadCompleted() const; | ||||
| private: | private: | ||||
| const ProjectService &project_service_; | |||||
| RuntimeState state_; | RuntimeState state_; | ||||
| // 表示 PLC 缓存是否已通过至少一次有效读取建立 | // 表示 PLC 缓存是否已通过至少一次有效读取建立 | ||||
| bool initial_plc_read_completed_ = false; | bool initial_plc_read_completed_ = false; | ||||
| @@ -138,6 +138,14 @@ public: | |||||
| painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); | painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); | ||||
| painter->drawRect(boundingRect().adjusted(0, 0, -1, -1)); | painter->drawRect(boundingRect().adjusted(0, 0, -1, -1)); | ||||
| } | } | ||||
| if (!control_.isConfigured()) | |||||
| { | |||||
| painter->setPen(QColor(QStringLiteral("#b54708"))); | |||||
| painter->drawText( | |||||
| rect.adjusted(2, rect.height() - 15, -2, 0), | |||||
| Qt::AlignCenter, | |||||
| QStringLiteral("未绑定")); | |||||
| } | |||||
| } | } | ||||
| // 向场景和外部控件返回该图元对应的领域控件标识 | // 向场景和外部控件返回该图元对应的领域控件标识 | ||||
| @@ -93,6 +93,12 @@ QString nodeToolTip(const LogicNodeConfig &config) | |||||
| config); | config); | ||||
| } | } | ||||
| QString nodeAddressText(const RegisterAddress &address, bool configured) | |||||
| { | |||||
| return configured ? registerAddressText(address) | |||||
| : LogicEditorWidget::tr("< M 地址 >"); | |||||
| } | |||||
| qreal rungHeight(const LadderRung &rung) | qreal rungHeight(const LadderRung &rung) | ||||
| { | { | ||||
| std::size_t maximum_branches = 1U; | std::size_t maximum_branches = 1U; | ||||
| @@ -139,12 +145,15 @@ public: | |||||
| : node_id_(node.id), | : node_id_(node.id), | ||||
| rung_id_(rung_id), | rung_id_(rung_id), | ||||
| stage_id_(stage_id), | stage_id_(stage_id), | ||||
| config_(node.config) | |||||
| config_(node.config), | |||||
| configured_(node.configured) | |||||
| { | { | ||||
| setPos(center); | setPos(center); | ||||
| setFlag(ItemIsSelectable, true); | setFlag(ItemIsSelectable, true); | ||||
| setZValue(1.0); | setZValue(1.0); | ||||
| setToolTip(nodeToolTip(config_)); | |||||
| setToolTip(configured_ ? nodeToolTip(config_) | |||||
| : LogicEditorWidget::tr( | |||||
| "待配置:请在属性区设置地址和参数")); | |||||
| } | } | ||||
| QRectF boundingRect() const override | QRectF boundingRect() const override | ||||
| @@ -185,7 +194,7 @@ public: | |||||
| painter->drawText( | painter->drawText( | ||||
| QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), | QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), | ||||
| Qt::AlignCenter, | Qt::AlignCenter, | ||||
| registerAddressText(contact->address)); | |||||
| nodeAddressText(contact->address, configured_)); | |||||
| } | } | ||||
| else if (const auto *coil = std::get_if<CoilNodeConfig>(&config_)) | else if (const auto *coil = std::get_if<CoilNodeConfig>(&config_)) | ||||
| { | { | ||||
| @@ -212,7 +221,7 @@ public: | |||||
| painter->drawText( | painter->drawText( | ||||
| QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), | QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), | ||||
| Qt::AlignCenter, | Qt::AlignCenter, | ||||
| registerAddressText(coil->address)); | |||||
| nodeAddressText(coil->address, configured_)); | |||||
| } | } | ||||
| else if (const auto *comparison = std::get_if<CompareNodeConfig>(&config_)) | else if (const auto *comparison = std::get_if<CompareNodeConfig>(&config_)) | ||||
| { | { | ||||
| @@ -232,11 +241,13 @@ public: | |||||
| painter->drawText( | painter->drawText( | ||||
| QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), | QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), | ||||
| Qt::AlignCenter, | Qt::AlignCenter, | ||||
| registerAddressText(comparison->address)); | |||||
| configured_ ? registerAddressText(comparison->address) | |||||
| : tr("< D 地址 >")); | |||||
| painter->drawText( | painter->drawText( | ||||
| QRectF(-kNodeWidth / 2.0, 22, kNodeWidth, 18), | QRectF(-kNodeWidth / 2.0, 22, kNodeWidth, 18), | ||||
| Qt::AlignCenter, | Qt::AlignCenter, | ||||
| QString::number(comparison->value)); | |||||
| configured_ ? QString::number(comparison->value) | |||||
| : LogicEditorWidget::tr("< 常量 >")); | |||||
| } | } | ||||
| } | } | ||||
| @@ -249,6 +260,7 @@ private: | |||||
| std::string rung_id_; | std::string rung_id_; | ||||
| std::string stage_id_; | std::string stage_id_; | ||||
| LogicNodeConfig config_; | LogicNodeConfig config_; | ||||
| bool configured_ = true; | |||||
| }; | }; | ||||
| class LogicEditorWidget::RungItem final : public QGraphicsItem | class LogicEditorWidget::RungItem final : public QGraphicsItem | ||||
| @@ -74,6 +74,10 @@ QString transitionErrorText(ModeTransitionError error) | |||||
| { | { | ||||
| return MainWindow::tr("当前已处于所选模式"); | return MainWindow::tr("当前已处于所选模式"); | ||||
| } | } | ||||
| case ModeTransitionError::ProjectNotReady: | |||||
| { | |||||
| return MainWindow::tr("工程存在未配置或未完成的 HMI/梯形图节点,暂不能进入离线运行态"); | |||||
| } | |||||
| case ModeTransitionError::None: | case ModeTransitionError::None: | ||||
| default: | default: | ||||
| { | { | ||||
| @@ -121,11 +125,6 @@ QString controlTypeText(HmiControlType type) | |||||
| } | } | ||||
| } | } | ||||
| bool usesMAddress(HmiControlType type) | |||||
| { | |||||
| return type == HmiControlType::Button || type == HmiControlType::Indicator; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| MainWindow::MainWindow( | MainWindow::MainWindow( | ||||
| @@ -481,6 +480,7 @@ void MainWindow::configurePropertyEditor() | |||||
| } | } | ||||
| binding_area_combo_box_ = new QComboBox(ui_->propertiesPage); | binding_area_combo_box_ = new QComboBox(ui_->propertiesPage); | ||||
| binding_area_combo_box_->setObjectName(QStringLiteral("bindingAreaComboBox")); | binding_area_combo_box_->setObjectName(QStringLiteral("bindingAreaComboBox")); | ||||
| binding_area_combo_box_->addItem(tr("未绑定"), QVariant::fromValue(-1)); | |||||
| binding_area_combo_box_->addItem(QStringLiteral("M"), QVariant::fromValue(0)); | binding_area_combo_box_->addItem(QStringLiteral("M"), QVariant::fromValue(0)); | ||||
| binding_area_combo_box_->addItem(QStringLiteral("D"), QVariant::fromValue(1)); | binding_area_combo_box_->addItem(QStringLiteral("D"), QVariant::fromValue(1)); | ||||
| binding_index_spin_box_ = new QSpinBox(ui_->propertiesPage); | binding_index_spin_box_ = new QSpinBox(ui_->propertiesPage); | ||||
| @@ -613,7 +613,9 @@ void MainWindow::showControlProperties(const std::string &control_id) | |||||
| // 更新属性面板顶部状态标签,显示选中控件ID或提示未选择 | // 更新属性面板顶部状态标签,显示选中控件ID或提示未选择 | ||||
| ui_->selectionValueLabel->setText( | ui_->selectionValueLabel->setText( | ||||
| has_control ? fromUtf8(control->id) : tr("未选择")); | |||||
| has_control ? fromUtf8(control->id) | |||||
| + (control->isConfigured() ? QString{} : tr("(未绑定)")) | |||||
| : tr("未选择")); | |||||
| // 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑 | // 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑 | ||||
| for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_), | for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_), | ||||
| @@ -646,15 +648,9 @@ void MainWindow::showControlProperties(const std::string &control_id) | |||||
| control_height_spin_box_->setValue(control->bounds.height); | control_height_spin_box_->setValue(control->bounds.height); | ||||
| // 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区 | // 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区 | ||||
| const RegisterArea default_area = usesMAddress(control->type) | |||||
| ? RegisterArea::M : RegisterArea::D; | |||||
| // 优先使用控件已绑定的寄存器区域;无绑定则使用类型对应的默认区域 | |||||
| const RegisterArea area = control->binding.has_value() | |||||
| ? control->binding->area() : default_area; | |||||
| // 同步寄存器区域下拉框选中项(0=M,1=D,与combo初始化顺序严格对应) | |||||
| binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); | |||||
| // 填充寄存器地址索引;无绑定时默认地址为0 | |||||
| // 未绑定必须保留明确状态,避免属性编辑把显示默认值误提交为 M0/D0 | |||||
| binding_area_combo_box_->setCurrentIndex(!control->binding.has_value() ? 0 | |||||
| : control->binding->area() == RegisterArea::M ? 1 : 2); | |||||
| binding_index_spin_box_->setValue( | binding_index_spin_box_->setValue( | ||||
| control->binding.has_value() ? control->binding->index() : 0); | control->binding.has_value() ? control->binding->index() : 0); | ||||
| } | } | ||||
| @@ -737,6 +733,11 @@ void MainWindow::showLogicNodeProperties(const std::string &node_id) | |||||
| logic_value_spin_box_->setEnabled(true); | logic_value_spin_box_->setEnabled(true); | ||||
| logic_value_spin_box_->setValue(compare.value); | logic_value_spin_box_->setValue(compare.value); | ||||
| } | } | ||||
| if (!node->configured) | |||||
| { | |||||
| logic_node_type_label_->setText(tr("待配置:%1").arg( | |||||
| logic_node_type_label_->text())); | |||||
| } | |||||
| } | } | ||||
| void MainWindow::addHmiControl(HmiControlType type) | void MainWindow::addHmiControl(HmiControlType type) | ||||
| @@ -790,9 +791,16 @@ void MainWindow::applySelectedControlProperties() | |||||
| control.bounds.y = control_y_spin_box_->value(); | control.bounds.y = control_y_spin_box_->value(); | ||||
| control.bounds.width = control_width_spin_box_->value(); | control.bounds.width = control_width_spin_box_->value(); | ||||
| control.bounds.height = control_height_spin_box_->value(); | control.bounds.height = control_height_spin_box_->value(); | ||||
| const RegisterArea area = binding_area_combo_box_->currentIndex() == 0 | |||||
| ? RegisterArea::M : RegisterArea::D; | |||||
| control.binding = RegisterAddress{area, binding_index_spin_box_->value()}; | |||||
| const int binding_area = binding_area_combo_box_->currentData().toInt(); | |||||
| if (binding_area < 0) | |||||
| { | |||||
| control.binding.reset(); | |||||
| } | |||||
| else | |||||
| { | |||||
| const RegisterArea area = binding_area == 0 ? RegisterArea::M : RegisterArea::D; | |||||
| control.binding = RegisterAddress{area, binding_index_spin_box_->value()}; | |||||
| } | |||||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | const HmiEditorResult result = hmi_editor_service_.updateControl( | ||||
| hmi_editor_service_.firstPageId(), selected_control_id_, control); | hmi_editor_service_.firstPageId(), selected_control_id_, control); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| @@ -907,7 +915,7 @@ void MainWindow::applySelectedLogicNodeProperties() | |||||
| logic_editor_widget_->selectNode(selected_logic_node_id_); | logic_editor_widget_->selectNode(selected_logic_node_id_); | ||||
| showLogicNodeProperties(selected_logic_node_id_); | showLogicNodeProperties(selected_logic_node_id_); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| statusBar()->showMessage(tr("逻辑节点属性已更新"), 3000); | |||||
| statusBar()->showMessage(tr("逻辑节点属性已更新,节点已配置"), 3000); | |||||
| } | } | ||||
| void MainWindow::createNewProject() | void MainWindow::createNewProject() | ||||
| @@ -170,13 +170,17 @@ void testLadderLogicBoundaries() | |||||
| require(!logic.validate(), "a ladder output must be a coil"); | require(!logic.validate(), "a ladder output must be a coil"); | ||||
| logic.rungs.front().output.reset(); | logic.rungs.front().output.reset(); | ||||
| require(!logic.validate(), "conditions without an output must fail full validation"); | |||||
| require(logic.validate(), "incomplete ladder may remain in an editable draft"); | |||||
| require(!logic.validateForRunning(), | |||||
| "conditions without an output must block runtime validation"); | |||||
| LadderRung empty_rung{"rung-empty", "Empty network", {}, std::nullopt}; | LadderRung empty_rung{"rung-empty", "Empty network", {}, std::nullopt}; | ||||
| require(empty_rung.validate(), "an empty editing network must be valid"); | require(empty_rung.validate(), "an empty editing network must be valid"); | ||||
| empty_rung.output = coil; | empty_rung.output = coil; | ||||
| require(!empty_rung.validate(), "an output without conditions must be rejected"); | |||||
| require(empty_rung.validate(), "output-only network may remain in an editable draft"); | |||||
| require(!empty_rung.validateForRunning(), | |||||
| "an output without conditions must block runtime validation"); | |||||
| logic.rungs.front().output = coil; | logic.rungs.front().output = coil; | ||||
| logic.rungs.front().stages.at(1).branches.at(1).id = start.id; | logic.rungs.front().stages.at(1).branches.at(1).id = start.id; | ||||
| @@ -208,6 +212,18 @@ void testModelsValidateBindingsAndIdentifiers() | |||||
| project = makeValidProject(); | project = makeValidProject(); | ||||
| project.hmiPages.front().controls.front().properties.emplace("", "value"); | project.hmiPages.front().controls.front().properties.emplace("", "value"); | ||||
| require(!project.validate(), "empty HMI property names must be rejected"); | require(!project.validate(), "empty HMI property names must be rejected"); | ||||
| project = makeValidProject(); | |||||
| project.hmiPages.front().controls.front().binding.reset(); | |||||
| require(project.validate(), "unbound HMI control must be accepted in a draft"); | |||||
| require(!project.validateForRunning(), | |||||
| "unbound HMI control must block runtime validation"); | |||||
| project = makeValidProject(); | |||||
| project.controlLogics.front().rungs.front().output->configured = false; | |||||
| require(project.validate(), "unconfigured ladder node must be accepted in a draft"); | |||||
| require(!project.validateForRunning(), | |||||
| "unconfigured ladder node must block runtime validation"); | |||||
| } | } | ||||
| void testRuntimeStateBoundaries() | void testRuntimeStateBoundaries() | ||||
| @@ -75,6 +75,19 @@ void testEditorOperations() | |||||
| require(hold_result.succeeded && coil_result.succeeded, | require(hold_result.succeeded && coil_result.succeeded, | ||||
| "parallel hold contact and output coil must be added"); | "parallel hold contact and output coil must be added"); | ||||
| require(!service.findNode(logic_id, stop_result.id)->configured, | |||||
| "new logic nodes must remain unconfigured until properties are applied"); | |||||
| require(service.updateNodeConfig( | |||||
| logic_id, | |||||
| stop_result.id, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| ContactMode::NormallyClosed}) | |||||
| .succeeded, | |||||
| "applying logic node properties must succeed"); | |||||
| require(service.findNode(logic_id, stop_result.id)->configured, | |||||
| "applying logic node properties must mark the node configured"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | const LadderRung *rung = service.findRung(logic_id, rung_id); | ||||
| require(rung != nullptr && rung->stages.size() == 2, | require(rung != nullptr && rung->stages.size() == 2, | ||||
| "series conditions must occupy ordered stages"); | "series conditions must occupy ordered stages"); | ||||
| @@ -62,7 +62,7 @@ void testModeActionsControlEditingAvailability() | |||||
| LogicEditorService logic_editor_service(project_service); | LogicEditorService logic_editor_service(project_service); | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| HmiRuntimeService runtime_service(repository); | HmiRuntimeService runtime_service(repository); | ||||
| RuntimeModeService mode_service; | |||||
| RuntimeModeService mode_service(project_service); | |||||
| MainWindow window( | MainWindow window( | ||||
| mode_service, | mode_service, | ||||
| project_service, | project_service, | ||||
| @@ -105,8 +105,8 @@ void testModeActionsControlEditingAvailability() | |||||
| const std::string page_id = editor_service.firstPageId(); | const std::string page_id = editor_service.firstPageId(); | ||||
| require(editor_service.findPage(page_id)->controls.size() == 1, | require(editor_service.findPage(page_id)->controls.size() == 1, | ||||
| "adding a control must update the HMI page model"); | "adding a control must update the HMI page model"); | ||||
| require(selection->text() == QStringLiteral("button-1"), | |||||
| "selecting an added control must update the property panel"); | |||||
| require(selection->text() == QStringLiteral("button-1(未绑定)"), | |||||
| "an unbound added control must be marked in the property panel"); | |||||
| require(text_edit->text() == QStringLiteral("按钮"), | require(text_edit->text() == QStringLiteral("按钮"), | ||||
| "property panel must show the control text"); | "property panel must show the control text"); | ||||
| delete_control_action->trigger(); | delete_control_action->trigger(); | ||||
| @@ -128,6 +128,29 @@ void testModeActionsControlEditingAvailability() | |||||
| "coil action must set the fixed ladder output"); | "coil action must set the fixed ladder output"); | ||||
| offline_action->trigger(); | offline_action->trigger(); | ||||
| require(mode_service.mode() == ApplicationMode::Editing, | |||||
| "unconfigured ladder nodes must block offline running"); | |||||
| const LadderRung &rung = logic->rungs.front(); | |||||
| for (const LogicNode &node : rung.stages.front().branches) | |||||
| { | |||||
| require(logic_editor_service.updateNodeConfig( | |||||
| logic_editor_service.firstLogicId(), | |||||
| node.id, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | |||||
| ContactMode::NormallyOpen}) | |||||
| .succeeded, | |||||
| "applying a contact configuration must complete the ladder node"); | |||||
| } | |||||
| require(logic_editor_service.updateNodeConfig( | |||||
| logic_editor_service.firstLogicId(), | |||||
| rung.output->id, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| CoilMode::Normal}) | |||||
| .succeeded, | |||||
| "applying a coil configuration must complete the ladder node"); | |||||
| offline_action->trigger(); | |||||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | require(mode_service.mode() == ApplicationMode::OfflineRunning, | ||||
| "offline action must enter offline running"); | "offline action must enter offline running"); | ||||
| require(!project_dock->isEnabled(), | require(!project_dock->isEnabled(), | ||||
| @@ -1,4 +1,6 @@ | |||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "services/project_service.h" | |||||
| #include "domain/project_storage.h" | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| @@ -6,6 +8,20 @@ | |||||
| namespace { | namespace { | ||||
| class TestProjectStorage final : public ProjectStorage | |||||
| { | |||||
| public: | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | |||||
| { | |||||
| return {true, ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectLoadResult load(const std::string &) override | |||||
| { | |||||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||||
| } | |||||
| }; | |||||
| void require(bool condition, const std::string &message) | void require(bool condition, const std::string &message) | ||||
| { | { | ||||
| if (!condition) | if (!condition) | ||||
| @@ -17,7 +33,9 @@ void require(bool condition, const std::string &message) | |||||
| void testModeTransitions() | void testModeTransitions() | ||||
| { | { | ||||
| // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | ||||
| RuntimeModeService service; | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| RuntimeModeService service(project_service); | |||||
| require(service.mode() == ApplicationMode::Editing, | require(service.mode() == ApplicationMode::Editing, | ||||
| "service must start in editing mode"); | "service must start in editing mode"); | ||||
| @@ -10,9 +10,20 @@ INCLUDEPATH += ../src | |||||
| SOURCES += \ | SOURCES += \ | ||||
| runtime_mode_service_tests.cpp \ | runtime_mode_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp \ | ../src/domain/runtime_state.cpp \ | ||||
| ../src/services/project_service.cpp \ | |||||
| ../src/services/runtime_mode_service.cpp | ../src/services/runtime_mode_service.cpp | ||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/domain/runtime_state.h \ | ../src/domain/runtime_state.h \ | ||||
| ../src/services/project_service.h \ | |||||
| ../src/services/runtime_mode_service.h | ../src/services/runtime_mode_service.h | ||||
| @@ -68,6 +68,11 @@ main.cpp -> UI + Services + Infrastructure | |||||
| 由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功 | 由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功 | ||||
| 后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。 | 后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。 | ||||
| 工程编辑支持保留草稿:HMI 非标签控件可以暂时没有寄存器绑定,梯形图节点可以处于待配置状态, | |||||
| 这些状态仍通过 `Project::validate()` 保存和加载。进入离线运行前由 | |||||
| `Project::validateForRunning()` 额外拒绝未绑定控件、待配置节点和缺少条件/输出的网络;旧版 1.0 | |||||
| 工程缺少节点 `configured` 字段时按历史完整节点兼容读取。 | |||||
| `services/HmiEditorService` 负责 HMI 页面的控件添加、删除、移动和属性更新,不依赖 Qt 图元。 | `services/HmiEditorService` 负责 HMI 页面的控件添加、删除、移动和属性更新,不依赖 Qt 图元。 | ||||
| `ui/HmiEditorWidget` 使用 `QGraphicsScene` 将模型投影为画布项,画布项只保存控件标识,移动结束后 | `ui/HmiEditorWidget` 使用 `QGraphicsScene` 将模型投影为画布项,画布项只保存控件标识,移动结束后 | ||||
| 才通过编辑服务提交新位置。保存时由 `Project::validate()` 拒绝缺失必需绑定或绑定区域错误的控件。 | 才通过编辑服务提交新位置。保存时由 `Project::validate()` 拒绝缺失必需绑定或绑定区域错误的控件。 | ||||