| @@ -58,4 +58,6 @@ Desktop.ini | |||||
| /scripts | /scripts | ||||
| /docs/images | /docs/images | ||||
| /docs/类图Mermaid代码.md | /docs/类图Mermaid代码.md | ||||
| /other | |||||
| /other | |||||
| 设计方案书.doc | |||||
| /学习 | |||||
| @@ -37,11 +37,11 @@ struct HmiRect | |||||
| */ | */ | ||||
| enum class HmiControlType | enum class HmiControlType | ||||
| { | { | ||||
| Button, | |||||
| Indicator, | |||||
| NumericDisplay, | |||||
| NumericInput, | |||||
| Label | |||||
| Button, // 按钮 | |||||
| Indicator, // 指示灯 | |||||
| NumericDisplay, // 数值显示 | |||||
| NumericInput, // 数值输入 | |||||
| Label // 标签 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -12,12 +12,7 @@ void setError(std::string *error, const std::string &message) | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * @brief 检查对象集合中是否存在重复 id | |||||
| * @tparam TItem 包含 id 成员的对象类型 | |||||
| * @param items 需要检查的对象集合 | |||||
| * @return 存在重复 id 时返回 true 否则返回 false | |||||
| */ | |||||
| // 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验 | |||||
| template <typename TItem> | template <typename TItem> | ||||
| bool containsDuplicateId(const std::vector<TItem> &items) | bool containsDuplicateId(const std::vector<TItem> &items) | ||||
| { | { | ||||
| @@ -59,6 +54,7 @@ bool Project::validate(std::string *error) const | |||||
| } | } | ||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||||
| if (!page.validate(error)) | if (!page.validate(error)) | ||||
| { | { | ||||
| return false; | return false; | ||||
| @@ -4,6 +4,7 @@ namespace { | |||||
| bool isSupportedArea(RegisterArea area) | bool isSupportedArea(RegisterArea area) | ||||
| { | { | ||||
| // 地址值对象只承认项目定义的 M 区和 D 区 | |||||
| return area == RegisterArea::M || area == RegisterArea::D; | return area == RegisterArea::M || area == RegisterArea::D; | ||||
| } | } | ||||
| @@ -43,6 +44,7 @@ std::string RegisterAddress::toString() const | |||||
| areaName = "D"; | areaName = "D"; | ||||
| break; | break; | ||||
| default: | default: | ||||
| // 未知枚举值不能伪装成有效 PLC 地址 | |||||
| return "InvalidRegisterAddress"; | return "InvalidRegisterAddress"; | ||||
| } | } | ||||
| @@ -12,6 +12,7 @@ ModePolicy RuntimeState::policy() const | |||||
| ModeTransitionResult RuntimeState::enterEditing() | ModeTransitionResult RuntimeState::enterEditing() | ||||
| { | { | ||||
| // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 | |||||
| if (mode_ == ApplicationMode::Editing) | if (mode_ == ApplicationMode::Editing) | ||||
| { | { | ||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | return {false, ModeTransitionError::AlreadyInRequestedMode}; | ||||
| @@ -46,6 +47,7 @@ ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_comp | |||||
| } | } | ||||
| if (!initial_plc_read_completed) | if (!initial_plc_read_completed) | ||||
| { | { | ||||
| // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 | |||||
| return {false, ModeTransitionError::InitialPlcReadRequired}; | return {false, ModeTransitionError::InitialPlcReadRequired}; | ||||
| } | } | ||||
| mode_ = ApplicationMode::OnlineRunning; | mode_ = ApplicationMode::OnlineRunning; | ||||
| @@ -30,15 +30,36 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||||
| { | { | ||||
| case ApplicationMode::Editing: | case ApplicationMode::Editing: | ||||
| { | { | ||||
| return {true, false, false, false, false}; | |||||
| // 编辑态只允许修改工程,不连接寄存器,也不执行软件逻辑 | |||||
| return { | |||||
| true, // allowsProjectEditing 允许编辑工程 | |||||
| false, // usesVirtualRegisters 不使用虚拟寄存器 | |||||
| false, // usesPlcRegisters 不使用 PLC 寄存器 | |||||
| false, // runsLogicExecutor 不运行软件逻辑执行器 | |||||
| false // requiresInitialPlcRead 不要求 PLC 初次读取 | |||||
| }; | |||||
| } | } | ||||
| case ApplicationMode::OfflineRunning: | case ApplicationMode::OfflineRunning: | ||||
| { | { | ||||
| return {false, true, false, true, false}; | |||||
| // 离线运行态禁止改工程,使用虚拟 M/D 并运行软件逻辑 | |||||
| return { | |||||
| false, // allowsProjectEditing 禁止编辑工程 | |||||
| true, // usesVirtualRegisters 使用虚拟寄存器 | |||||
| false, // usesPlcRegisters 不使用 PLC 寄存器 | |||||
| true, // runsLogicExecutor 运行软件逻辑执行器 | |||||
| false // requiresInitialPlcRead 不要求 PLC 初次读取 | |||||
| }; | |||||
| } | } | ||||
| case ApplicationMode::OnlineRunning: | case ApplicationMode::OnlineRunning: | ||||
| { | { | ||||
| return {false, false, true, false, true}; | |||||
| // 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC | |||||
| return { | |||||
| false, // allowsProjectEditing 禁止编辑工程 | |||||
| false, // usesVirtualRegisters 不使用虚拟寄存器 | |||||
| true, // usesPlcRegisters 使用 PLC 寄存器 | |||||
| false, // runsLogicExecutor 不运行软件逻辑执行器 | |||||
| true // requiresInitialPlcRead 必须先完成 PLC 初次读取 | |||||
| }; | |||||
| } | } | ||||
| default: | default: | ||||
| { | { | ||||
| @@ -21,9 +21,11 @@ int main(int argc, char *argv[]) | |||||
| application.setApplicationName(QObject::tr("综合平台编程器")); | application.setApplicationName(QObject::tr("综合平台编程器")); | ||||
| application.setOrganizationName(QStringLiteral("QtProXinJe")); | application.setOrganizationName(QStringLiteral("QtProXinJe")); | ||||
| // 组合根负责创建具体实现并将抽象依赖注入服务和 UI | |||||
| JsonProjectStorage project_storage; | JsonProjectStorage project_storage; | ||||
| ProjectService project_service(project_storage); | ProjectService project_service(project_storage); | ||||
| HmiEditorService hmi_editor_service(project_service); | HmiEditorService hmi_editor_service(project_service); | ||||
| // 当前离线模式使用内存仓库,后续真机模式替换为 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; | ||||
| @@ -83,6 +83,7 @@ HmiEditorService::HmiEditorService(ProjectService &project_service) | |||||
| { | { | ||||
| } | } | ||||
| // 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 | |||||
| const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | ||||
| { | { | ||||
| const Project &project = project_service_.project(); | const Project &project = project_service_.project(); | ||||
| @@ -96,6 +97,7 @@ const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | |||||
| return page == project.hmiPages.cend() ? nullptr : &*page; | return page == project.hmiPages.cend() ? nullptr : &*page; | ||||
| } | } | ||||
| // 先找到页面 → 在页面内查找指定控件;页面不存在 / 控件不存在都返回 nullptr | |||||
| const HmiControl *HmiEditorService::findControl( | const HmiControl *HmiEditorService::findControl( | ||||
| const std::string &page_id, const std::string &control_id) const | const std::string &page_id, const std::string &control_id) const | ||||
| { | { | ||||
| @@ -114,12 +116,14 @@ const HmiControl *HmiEditorService::findControl( | |||||
| return control == page->controls.cend() ? nullptr : &*control; | return control == page->controls.cend() ? nullptr : &*control; | ||||
| } | } | ||||
| // 获取第一个页面 ID;没有页面返回空字符串 | |||||
| std::string HmiEditorService::firstPageId() const | std::string HmiEditorService::firstPageId() const | ||||
| { | { | ||||
| const Project &project = project_service_.project(); | const Project &project = project_service_.project(); | ||||
| return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id; | return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id; | ||||
| } | } | ||||
| // 保证工程至少存在一个 HMI 页面 | |||||
| HmiEditorResult HmiEditorService::ensureDefaultPage() | HmiEditorResult HmiEditorService::ensureDefaultPage() | ||||
| { | { | ||||
| if (!project_service_.project().hmiPages.empty()) | if (!project_service_.project().hmiPages.empty()) | ||||
| @@ -127,6 +131,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||||
| return {true, HmiEditorError::None, {}, firstPageId()}; | return {true, HmiEditorError::None, {}, firstPageId()}; | ||||
| } | } | ||||
| // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "page-1"; | page.id = "page-1"; | ||||
| page.name = "主操作页面"; | page.name = "主操作页面"; | ||||
| @@ -145,6 +150,7 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| return failure(HmiEditorError::PageNotFound, "HMI page was not found"); | return failure(HmiEditorError::PageNotFound, "HMI page was not found"); | ||||
| } | } | ||||
| // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | |||||
| HmiControl control = makeControl(*page, type); | HmiControl control = makeControl(*page, type); | ||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target_page = std::find_if( | auto target_page = std::find_if( | ||||
| @@ -193,6 +199,7 @@ HmiEditorResult HmiEditorService::removeControl( | |||||
| return {true, HmiEditorError::None, {}, control_id}; | return {true, HmiEditorError::None, {}, control_id}; | ||||
| } | } | ||||
| // 移动 / 缩放控件 | |||||
| HmiEditorResult HmiEditorService::moveControl( | HmiEditorResult HmiEditorService::moveControl( | ||||
| const std::string &page_id, | const std::string &page_id, | ||||
| const std::string &control_id, | const std::string &control_id, | ||||
| @@ -209,8 +216,10 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); | return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); | ||||
| } | } | ||||
| // 先校验候选位置,失败时不修改工程模型 | |||||
| HmiControl candidate = *control; | HmiControl candidate = *control; | ||||
| candidate.bounds = bounds; | candidate.bounds = bounds; | ||||
| // 更新前保留原控件,只有全部编辑规则通过才覆盖原值 | |||||
| std::string error; | std::string error; | ||||
| if (!validateEditableControl(*page, candidate, &error)) | if (!validateEditableControl(*page, candidate, &error)) | ||||
| { | { | ||||
| @@ -228,6 +237,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| return {true, HmiEditorError::None, {}, control_id}; | return {true, HmiEditorError::None, {}, control_id}; | ||||
| } | } | ||||
| // 更新控件全部属性 | |||||
| HmiEditorResult HmiEditorService::updateControl( | HmiEditorResult HmiEditorService::updateControl( | ||||
| const std::string &page_id, | const std::string &page_id, | ||||
| const std::string &control_id, | const std::string &control_id, | ||||
| @@ -327,10 +337,11 @@ bool HmiEditorService::hasDuplicateControlId( | |||||
| page.controls.cbegin(), page.controls.cend(), | page.controls.cbegin(), page.controls.cend(), | ||||
| [&excluded_id, &candidate_id](const HmiControl &item) | [&excluded_id, &candidate_id](const HmiControl &item) | ||||
| { | { | ||||
| return item.id != excluded_id && item.id == candidate_id; | |||||
| return item.id != excluded_id && item.id == candidate_id; // 如果这个控件不是正在编辑的原控件,并且它的 ID 等于准备使用的新 ID | |||||
| }); | }); | ||||
| } | } | ||||
| // 根据控件类型生成默认样式、文字、大小、初始坐标 | |||||
| HmiControl HmiEditorService::makeControl( | HmiControl HmiEditorService::makeControl( | ||||
| const HmiPage &page, HmiControlType type) | const HmiPage &page, HmiControlType type) | ||||
| { | { | ||||
| @@ -346,9 +357,11 @@ HmiControl HmiEditorService::makeControl( | |||||
| return control; | return control; | ||||
| } | } | ||||
| // 生成页面内不重复控件 ID,前缀 + 自增数字 | |||||
| std::string HmiEditorService::makeUniqueId( | std::string HmiEditorService::makeUniqueId( | ||||
| const HmiPage &page, const std::string &prefix) | const HmiPage &page, const std::string &prefix) | ||||
| { | { | ||||
| // 同类控件从 1 递增命名,保证页面内标识稳定且唯一 | |||||
| int suffix = 1; | int suffix = 1; | ||||
| while (true) | while (true) | ||||
| { | { | ||||
| @@ -118,18 +118,47 @@ public: | |||||
| const HmiControl &control); | const HmiControl &control); | ||||
| private: | private: | ||||
| /** | |||||
| * @brief 校验控件编辑后仍满足页面内的基础约束 | |||||
| * @param page 控件所属页面 | |||||
| * @param control 待校验控件 | |||||
| * @param error 校验失败时写入原因,可为 nullptr | |||||
| * @return 控件标识、尺寸、位置和寄存器绑定区域均有效时返回 true | |||||
| */ | |||||
| static bool validateEditableControl( | static bool validateEditableControl( | ||||
| const HmiPage &page, | const HmiPage &page, | ||||
| const HmiControl &control, | const HmiControl &control, | ||||
| std::string *error); | std::string *error); | ||||
| /** | |||||
| * @brief 检查候选控件标识是否与页面内其他控件重复 | |||||
| * @param page 待检查页面 | |||||
| * @param excluded_id 更新场景中需要排除的原控件标识 | |||||
| * @param candidate_id 待使用的控件标识 | |||||
| * @return 存在同标识的其他控件时返回 true | |||||
| */ | |||||
| static bool hasDuplicateControlId( | static bool hasDuplicateControlId( | ||||
| const HmiPage &page, | const HmiPage &page, | ||||
| const std::string &excluded_id, | const std::string &excluded_id, | ||||
| const std::string &candidate_id); | const std::string &candidate_id); | ||||
| /** | |||||
| * @brief 按控件类型创建带默认属性的新控件 | |||||
| * @param page 新控件所属页面,用于确定初始位置和唯一标识 | |||||
| * @param type 新控件类型 | |||||
| * @return 未绑定寄存器的默认控件配置 | |||||
| */ | |||||
| static HmiControl makeControl( | static HmiControl makeControl( | ||||
| const HmiPage &page, HmiControlType type); | const HmiPage &page, HmiControlType type); | ||||
| /** | |||||
| * @brief 在页面内生成带指定前缀的唯一控件标识 | |||||
| * @param page 待检查页面 | |||||
| * @param prefix 控件类型对应的标识前缀 | |||||
| * @return 从 1 开始递增且不与现有控件重复的标识 | |||||
| */ | |||||
| static std::string makeUniqueId( | static std::string makeUniqueId( | ||||
| const HmiPage &page, const std::string &prefix); | const HmiPage &page, const std::string &prefix); | ||||
| /** | |||||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | |||||
| */ | |||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| }; | }; | ||||
| @@ -32,6 +32,7 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||||
| HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | ||||
| { | { | ||||
| // 标签等静态控件不参与寄存器读写 | |||||
| if (!isBitControl(control.type) && !isWordControl(control.type)) | if (!isBitControl(control.type) && !isWordControl(control.type)) | ||||
| { | { | ||||
| return readFailure(HmiRuntimeError::UnsupportedControl); | return readFailure(HmiRuntimeError::UnsupportedControl); | ||||
| @@ -46,6 +47,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| } | } | ||||
| if (isBitControl(control.type)) | if (isBitControl(control.type)) | ||||
| { | { | ||||
| // 按控件类型选择仓库的 M 位读取契约 | |||||
| const BitReadResult result = repository_.readBit(*control.binding); | const BitReadResult result = repository_.readBit(*control.binding); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| @@ -76,6 +78,7 @@ HmiRuntimeWriteResult HmiRuntimeService::toggleButton(const HmiControl &control) | |||||
| { | { | ||||
| return writeFailure(current.error); | return writeFailure(current.error); | ||||
| } | } | ||||
| // 先读后取反实现按钮点击切换 M 位语义 | |||||
| const RegisterWriteResult result = repository_.writeBit( | const RegisterWriteResult result = repository_.writeBit( | ||||
| *control.binding, !current.bit_value); | *control.binding, !current.bit_value); | ||||
| return result.succeeded | return result.succeeded | ||||
| @@ -106,6 +109,7 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||||
| HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error) | HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error) | ||||
| { | { | ||||
| // 领域仓库错误被收敛为 HMI 可理解的运行错误 | |||||
| switch (error) | switch (error) | ||||
| { | { | ||||
| case RegisterError::InvalidAddress: | case RegisterError::InvalidAddress: | ||||
| @@ -17,11 +17,11 @@ | |||||
| */ | */ | ||||
| enum class HmiRuntimeError | enum class HmiRuntimeError | ||||
| { | { | ||||
| None, | |||||
| UnsupportedControl, | |||||
| MissingBinding, | |||||
| InvalidBinding, | |||||
| RepositoryFailure | |||||
| None, // 无错误 | |||||
| UnsupportedControl, // 控件类型不支持寄存器读写 | |||||
| MissingBinding, // 控件未配置寄存器绑定 | |||||
| InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | |||||
| RepositoryFailure // 寄存器仓库读取或写入失败 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -9,7 +9,7 @@ | |||||
| namespace { | namespace { | ||||
| // 使用高精度时间戳和随机值生成工程标识 | |||||
| // 生成唯一工程 ID:project-时间戳-随机数 | |||||
| std::string generateProjectId() | std::string generateProjectId() | ||||
| { | { | ||||
| const auto timestamp = static_cast<unsigned long long>( | const auto timestamp = static_cast<unsigned long long>( | ||||
| @@ -51,6 +51,7 @@ bool ProjectService::hasCurrentFile() const | |||||
| return !current_file_path_.empty(); | return !current_file_path_.empty(); | ||||
| } | } | ||||
| // 查询是否存在未保存修改 | |||||
| bool ProjectService::isModified() const | bool ProjectService::isModified() const | ||||
| { | { | ||||
| return modified_; | return modified_; | ||||
| @@ -82,6 +83,7 @@ ProjectOperationResult ProjectService::save() | |||||
| ProjectStorageError::None, | ProjectStorageError::None, | ||||
| "project file path is required"}; | "project file path is required"}; | ||||
| } | } | ||||
| // 复用另存为流程,确保两种保存方式拥有相同的校验和错误处理 | |||||
| return saveAs(current_file_path_); | return saveAs(current_file_path_); | ||||
| } | } | ||||
| @@ -149,15 +151,18 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | ||||
| } | } | ||||
| // 创建一个全新空白工程实例 | |||||
| Project ProjectService::makeNewProject(const std::string &name) | Project ProjectService::makeNewProject(const std::string &name) | ||||
| { | { | ||||
| Project project; | Project project; | ||||
| project.metadata.id = generateProjectId(); | project.metadata.id = generateProjectId(); | ||||
| project.metadata.name = name; | project.metadata.name = name; | ||||
| // 新工程固定使用当前存储格式版本 | |||||
| project.metadata.formatVersion = "1.0"; | project.metadata.formatVersion = "1.0"; | ||||
| return project; | return project; | ||||
| } | } | ||||
| // 判断字符串是不是空白:空字符串 / 全是空格、制表符都算空白 | |||||
| bool ProjectService::isBlank(const std::string &value) | bool ProjectService::isBlank(const std::string &value) | ||||
| { | { | ||||
| return value.empty() | return value.empty() | ||||
| @@ -170,6 +175,7 @@ bool ProjectService::isBlank(const std::string &value) | |||||
| }); | }); | ||||
| } | } | ||||
| // 包装存储层失败结果 | |||||
| ProjectOperationResult ProjectService::storageFailure( | ProjectOperationResult ProjectService::storageFailure( | ||||
| ProjectStorageError error, const std::string &message) | ProjectStorageError error, const std::string &message) | ||||
| { | { | ||||
| @@ -30,11 +30,13 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | ModeTransitionResult RuntimeModeService::enterOnlineRunning() | ||||
| { | { | ||||
| // 首次 PLC 读取状态由服务维护,再交给领域状态机统一裁决 | |||||
| return state_.enterOnlineRunning(initial_plc_read_completed_); | return state_.enterOnlineRunning(initial_plc_read_completed_); | ||||
| } | } | ||||
| void RuntimeModeService::setInitialPlcReadCompleted(bool completed) | void RuntimeModeService::setInitialPlcReadCompleted(bool completed) | ||||
| { | { | ||||
| // 通信服务完成有效读回后才允许把此标志设为 true | |||||
| initial_plc_read_completed_ = completed; | initial_plc_read_completed_ = completed; | ||||
| } | } | ||||
| @@ -14,15 +14,44 @@ | |||||
| class RuntimeModeService | class RuntimeModeService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 获取当前应用运行模式 | |||||
| * @return 当前的编辑、离线运行或真机运行模式 | |||||
| */ | |||||
| ApplicationMode mode() const; | ApplicationMode mode() const; | ||||
| /** | |||||
| * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | |||||
| * @return 与当前运行模式对应的能力策略 | |||||
| */ | |||||
| ModePolicy policy() const; | ModePolicy policy() const; | ||||
| /** | |||||
| * @brief 退出离线或真机运行态并返回编辑态 | |||||
| * @return 已处于编辑态或状态机拒绝切换时返回失败结果 | |||||
| */ | |||||
| ModeTransitionResult enterEditing(); | ModeTransitionResult enterEditing(); | ||||
| /** | |||||
| * @brief 从编辑态进入离线运行态 | |||||
| * @return 非编辑态进入时返回必须先回到编辑态的失败结果 | |||||
| */ | |||||
| ModeTransitionResult enterOfflineRunning(); | ModeTransitionResult enterOfflineRunning(); | ||||
| /** | |||||
| * @brief 从编辑态进入真机运行态 | |||||
| * @return 未完成 PLC 初次读取或非编辑态进入时返回失败结果 | |||||
| */ | |||||
| ModeTransitionResult enterOnlineRunning(); | ModeTransitionResult enterOnlineRunning(); | ||||
| // 由后续 PLC 通信服务在首次读取成功或缓存失效时更新 | |||||
| /** | |||||
| * @brief 更新 PLC 是否已完成首次有效读取 | |||||
| * @param completed 为 true 时允许在编辑态进入真机运行模式 | |||||
| * | |||||
| * 由 PLC 通信服务在首次读取成功或缓存失效时调用 | |||||
| */ | |||||
| void setInitialPlcReadCompleted(bool completed); | void setInitialPlcReadCompleted(bool completed); | ||||
| /** | |||||
| * @brief 查询 PLC 是否已完成首次有效读取 | |||||
| * @return 已建立可用于进入真机运行态的 PLC 缓存时返回 true | |||||
| */ | |||||
| bool initialPlcReadCompleted() const; | bool initialPlcReadCompleted() const; | ||||
| private: | private: | ||||
| @@ -20,9 +20,11 @@ | |||||
| namespace { | namespace { | ||||
| // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 | |||||
| class HmiGraphicsItem final : public QGraphicsItem | class HmiGraphicsItem final : public QGraphicsItem | ||||
| { | { | ||||
| public: | public: | ||||
| // 保存控件快照、页面边界和回调,使图元不直接依赖服务层 | |||||
| HmiGraphicsItem( | HmiGraphicsItem( | ||||
| const HmiControl &control, | const HmiControl &control, | ||||
| int page_width, | int page_width, | ||||
| @@ -35,11 +37,17 @@ public: | |||||
| moved_(std::move(moved)), | moved_(std::move(moved)), | ||||
| activated_(std::move(activated)) | activated_(std::move(activated)) | ||||
| { | { | ||||
| // 领域坐标直接作为图元在场景中的初始位置 | |||||
| setPos(control_.bounds.x, control_.bounds.y); | setPos(control_.bounds.x, control_.bounds.y); | ||||
| // 所有控件均可选中,便于主窗口显示对应属性 | |||||
| setFlag(ItemIsSelectable, true); | setFlag(ItemIsSelectable, true); | ||||
| // 开启可选中 | |||||
| setFlag(ItemSendsGeometryChanges, true); | |||||
| // 画布只处理左键交互,保留其他按键给视图默认行为 | |||||
| setAcceptedMouseButtons(Qt::LeftButton); | setAcceptedMouseButtons(Qt::LeftButton); | ||||
| } | } | ||||
| // 返回图元自身坐标系中的矩形范围,用于绘制和命中测试 | |||||
| QRectF boundingRect() const override | QRectF boundingRect() const override | ||||
| { | { | ||||
| return {0, | return {0, | ||||
| @@ -53,6 +61,7 @@ public: | |||||
| const QStyleOptionGraphicsItem *option, | const QStyleOptionGraphicsItem *option, | ||||
| QWidget *) override | QWidget *) override | ||||
| { | { | ||||
| // 留出一个像素边距,避免描边被图元边界裁剪 | |||||
| const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); | const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); | ||||
| painter->setRenderHint(QPainter::Antialiasing, true); | painter->setRenderHint(QPainter::Antialiasing, true); | ||||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | ||||
| @@ -61,6 +70,7 @@ public: | |||||
| { | { | ||||
| case HmiControlType::Button: | case HmiControlType::Button: | ||||
| { | { | ||||
| // 按钮显示文字,运行态点击行为由鼠标事件处理 | |||||
| painter->setBrush(QColor(QStringLiteral("#dcece3"))); | painter->setBrush(QColor(QStringLiteral("#dcece3"))); | ||||
| painter->drawRoundedRect(rect, 4, 4); | painter->drawRoundedRect(rect, 4, 4); | ||||
| painter->setPen(QColor(QStringLiteral("#205c3b"))); | painter->setPen(QColor(QStringLiteral("#205c3b"))); | ||||
| @@ -69,6 +79,7 @@ public: | |||||
| } | } | ||||
| case HmiControlType::Indicator: | case HmiControlType::Indicator: | ||||
| { | { | ||||
| // 指示灯颜色由最近一次读取到的 M 位值决定 | |||||
| const qreal diameter = std::min(rect.width(), rect.height() - 18.0); | const qreal diameter = std::min(rect.width(), rect.height() - 18.0); | ||||
| const QRectF lamp( | const QRectF lamp( | ||||
| rect.center().x() - diameter / 2.0, | rect.center().x() - diameter / 2.0, | ||||
| @@ -88,6 +99,7 @@ public: | |||||
| } | } | ||||
| case HmiControlType::NumericDisplay: | case HmiControlType::NumericDisplay: | ||||
| { | { | ||||
| // 数值显示为只读样式,文本由运行值刷新 | |||||
| painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | ||||
| painter->drawRect(rect); | painter->drawRect(rect); | ||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | painter->setPen(QColor(QStringLiteral("#24313b"))); | ||||
| @@ -98,6 +110,7 @@ public: | |||||
| } | } | ||||
| case HmiControlType::NumericInput: | case HmiControlType::NumericInput: | ||||
| { | { | ||||
| // 数值输入以白色编辑框样式呈现,双击后才请求写入 | |||||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | painter->setBrush(QColor(QStringLiteral("#ffffff"))); | ||||
| painter->drawRoundedRect(rect, 3, 3); | painter->drawRoundedRect(rect, 3, 3); | ||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | painter->setPen(QColor(QStringLiteral("#24313b"))); | ||||
| @@ -109,6 +122,7 @@ public: | |||||
| case HmiControlType::Label: | case HmiControlType::Label: | ||||
| default: | default: | ||||
| { | { | ||||
| // 标签只显示固定文本,不绑定寄存器运行值 | |||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | painter->setPen(QColor(QStringLiteral("#24313b"))); | ||||
| painter->drawText(rect, Qt::AlignCenter, | painter->drawText(rect, Qt::AlignCenter, | ||||
| QString::fromUtf8(control_.text.data(), | QString::fromUtf8(control_.text.data(), | ||||
| @@ -119,22 +133,26 @@ public: | |||||
| if ((option->state & QStyle::State_Selected) != 0) | if ((option->state & QStyle::State_Selected) != 0) | ||||
| { | { | ||||
| // 选中框独立于控件类型,提示当前可编辑对象 | |||||
| painter->setBrush(Qt::NoBrush); | painter->setBrush(Qt::NoBrush); | ||||
| 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)); | ||||
| } | } | ||||
| } | } | ||||
| // 向场景和外部控件返回该图元对应的领域控件标识 | |||||
| const std::string &controlId() const | const std::string &controlId() const | ||||
| { | { | ||||
| return control_.id; | return control_.id; | ||||
| } | } | ||||
| // 编辑态允许拖动,运行态保留点击或双击交互 | |||||
| void setInteractionEnabled(bool editable) | void setInteractionEnabled(bool editable) | ||||
| { | { | ||||
| setFlag(ItemIsMovable, editable); | setFlag(ItemIsMovable, editable); | ||||
| } | } | ||||
| // 缓存运行服务读出的值并触发 Qt 重绘 | |||||
| void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) | void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) | ||||
| { | { | ||||
| bit_value_ = bit_value; | bit_value_ = bit_value; | ||||
| @@ -144,10 +162,13 @@ public: | |||||
| } | } | ||||
| protected: | protected: | ||||
| // 拖拽过程中将新位置限制在页面可见边界内 | |||||
| QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | ||||
| { | { | ||||
| // 只有开启 ItemIsMovable 拖拽时,才做坐标钳位 | |||||
| if (change == ItemPositionChange && flags().testFlag(ItemIsMovable)) | if (change == ItemPositionChange && flags().testFlag(ItemIsMovable)) | ||||
| { | { | ||||
| // 在图元层预先截断拖拽坐标,避免控件视觉上越出页面 | |||||
| QPointF position = value.toPointF(); | QPointF position = value.toPointF(); | ||||
| const qreal maximum_x = std::max( | const qreal maximum_x = std::max( | ||||
| 0.0, static_cast<double>(page_width_ - control_.bounds.width)); | 0.0, static_cast<double>(page_width_ - control_.bounds.width)); | ||||
| @@ -160,24 +181,30 @@ protected: | |||||
| return QGraphicsItem::itemChange(change, value); | return QGraphicsItem::itemChange(change, value); | ||||
| } | } | ||||
| // 运行态点击按钮时通知外层执行 M 位切换 | |||||
| void mousePressEvent(QGraphicsSceneMouseEvent *event) override | void mousePressEvent(QGraphicsSceneMouseEvent *event) override | ||||
| { | { | ||||
| // 条件:!ItemIsMovable 【也就是运行模式】 + 是按钮 + 有回调 | |||||
| if (!flags().testFlag(ItemIsMovable) | if (!flags().testFlag(ItemIsMovable) | ||||
| && control_.type == HmiControlType::Button && activated_) | && control_.type == HmiControlType::Button && activated_) | ||||
| { | { | ||||
| // 运行态单击按钮才触发 M 位切换,编辑态只用于选择和拖动 | |||||
| setSelected(true); | setSelected(true); | ||||
| activated_(control_.id); | activated_(control_.id); | ||||
| event->accept(); | event->accept(); | ||||
| return; | return; | ||||
| } | } | ||||
| // 编辑模式:不进if分支,执行基类事件——只做选中、拖拽 | |||||
| QGraphicsItem::mousePressEvent(event); | QGraphicsItem::mousePressEvent(event); | ||||
| } | } | ||||
| // 运行态双击数值输入时通知外层弹出数值编辑对话框 | |||||
| void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override | void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override | ||||
| { | { | ||||
| if (!flags().testFlag(ItemIsMovable) | if (!flags().testFlag(ItemIsMovable) | ||||
| && control_.type == HmiControlType::NumericInput && activated_) | && control_.type == HmiControlType::NumericInput && activated_) | ||||
| { | { | ||||
| // 数值输入使用双击,避免普通选择操作意外写入 D 字 | |||||
| setSelected(true); | setSelected(true); | ||||
| activated_(control_.id); | activated_(control_.id); | ||||
| event->accept(); | event->accept(); | ||||
| @@ -186,9 +213,11 @@ protected: | |||||
| QGraphicsItem::mouseDoubleClickEvent(event); | QGraphicsItem::mouseDoubleClickEvent(event); | ||||
| } | } | ||||
| // 拖拽结束后才提交最终坐标,避免移动过程频繁修改领域模型 | |||||
| void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override | void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override | ||||
| { | { | ||||
| QGraphicsItem::mouseReleaseEvent(event); | QGraphicsItem::mouseReleaseEvent(event); | ||||
| // 只有ItemIsMovable打开(编辑态),松开鼠标才提交位置给业务层 | |||||
| if (flags().testFlag(ItemIsMovable) && moved_) | if (flags().testFlag(ItemIsMovable) && moved_) | ||||
| { | { | ||||
| moved_(control_.id, pos()); | moved_(control_.id, pos()); | ||||
| @@ -196,6 +225,7 @@ protected: | |||||
| } | } | ||||
| private: | private: | ||||
| // 根据控件类型和运行数据组合当前应绘制的文字 | |||||
| QString textWithValue() const | QString textWithValue() const | ||||
| { | { | ||||
| const QString text = QString::fromUtf8( | const QString text = QString::fromUtf8( | ||||
| @@ -212,16 +242,25 @@ private: | |||||
| return text; | return text; | ||||
| } | } | ||||
| // 图元创建时的领域控件快照,提供类型、尺寸、文本和标识 | |||||
| HmiControl control_; | HmiControl control_; | ||||
| // 当前页面宽度,用于限制图元横向拖拽范围 | |||||
| int page_width_ = 0; | int page_width_ = 0; | ||||
| // 当前页面高度,用于限制图元纵向拖拽范围 | |||||
| int page_height_ = 0; | int page_height_ = 0; | ||||
| // 拖拽完成后回调 HmiEditorWidget 提交控件新位置 | |||||
| std::function<void(const std::string &, const QPointF &)> moved_; | std::function<void(const std::string &, const QPointF &)> moved_; | ||||
| // 运行模式点击按钮 / 双击输入框回调,通知外层做寄存器读写 | |||||
| std::function<void(const std::string &)> activated_; | std::function<void(const std::string &)> activated_; | ||||
| // 指示灯读取到的 M 位值 | |||||
| bool bit_value_ = false; | bool bit_value_ = false; | ||||
| // 数值控件读取到的 D 字值 | |||||
| std::int16_t word_value_ = 0; | std::int16_t word_value_ = 0; | ||||
| // 标记当前缓存值是否来自一次成功的运行时读取 | |||||
| bool has_runtime_value_ = false; | bool has_runtime_value_ = false; | ||||
| }; | }; | ||||
| // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 | |||||
| HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | ||||
| { | { | ||||
| return dynamic_cast<HmiGraphicsItem *>(item); | return dynamic_cast<HmiGraphicsItem *>(item); | ||||
| @@ -247,6 +286,7 @@ HmiEditorWidget::HmiEditorWidget( | |||||
| this, &HmiEditorWidget::handleSelectionChanged); | this, &HmiEditorWidget::handleSelectionChanged); | ||||
| } | } | ||||
| // 切换显示页面 | |||||
| void HmiEditorWidget::setPageId(const std::string &page_id) | void HmiEditorWidget::setPageId(const std::string &page_id) | ||||
| { | { | ||||
| if (page_id_ == page_id) | if (page_id_ == page_id) | ||||
| @@ -280,8 +320,10 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||||
| } | } | ||||
| } | } | ||||
| // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | |||||
| void HmiEditorWidget::reloadPage() | void HmiEditorWidget::reloadPage() | ||||
| { | { | ||||
| // 画布始终从当前领域页面重建,避免保留已删除控件的图元 | |||||
| scene_->clear(); | scene_->clear(); | ||||
| const HmiPage *page = editor_service_.findPage(page_id_); | const HmiPage *page = editor_service_.findPage(page_id_); | ||||
| if (page == nullptr) | if (page == nullptr) | ||||
| @@ -318,6 +360,7 @@ void HmiEditorWidget::reloadPage() | |||||
| refreshRuntimeValues(); | refreshRuntimeValues(); | ||||
| } | } | ||||
| // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | |||||
| void HmiEditorWidget::selectControl(const std::string &control_id) | void HmiEditorWidget::selectControl(const std::string &control_id) | ||||
| { | { | ||||
| for (QGraphicsItem *item : scene_->items()) | for (QGraphicsItem *item : scene_->items()) | ||||
| @@ -365,6 +408,7 @@ void HmiEditorWidget::refreshRuntimeValues() | |||||
| { | { | ||||
| continue; | continue; | ||||
| } | } | ||||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | |||||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | ||||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | ||||
| } | } | ||||
| @@ -389,6 +433,7 @@ void HmiEditorWidget::fitCurrentPage() | |||||
| void HmiEditorWidget::updateItemInteractions() | void HmiEditorWidget::updateItemInteractions() | ||||
| { | { | ||||
| // 遍历所有控件图元,统一设置flag | |||||
| for (QGraphicsItem *item : scene_->items()) | for (QGraphicsItem *item : scene_->items()) | ||||
| { | { | ||||
| HmiGraphicsItem *control_item = asHmiItem(item); | HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| @@ -412,6 +457,7 @@ void HmiEditorWidget::handleControlMoved( | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 鼠标坐标取整后再交给服务校验并写回模型 | |||||
| HmiRect bounds = control->bounds; | HmiRect bounds = control->bounds; | ||||
| bounds.x = static_cast<int>(std::lround(position.x())); | bounds.x = static_cast<int>(std::lround(position.x())); | ||||
| bounds.y = static_cast<int>(std::lround(position.y())); | bounds.y = static_cast<int>(std::lround(position.y())); | ||||
| @@ -438,6 +484,7 @@ void HmiEditorWidget::handleControlActivated(const std::string &control_id) | |||||
| return; | return; | ||||
| } | } | ||||
| HmiRuntimeWriteResult result; | HmiRuntimeWriteResult result; | ||||
| // 只有可写控件允许激活,其余控件只展示最新运行值 | |||||
| if (control->type == HmiControlType::Button) | if (control->type == HmiControlType::Button) | ||||
| { | { | ||||
| result = runtime_service_.toggleButton(*control); | result = runtime_service_.toggleButton(*control); | ||||
| @@ -92,16 +92,27 @@ protected: | |||||
| void resizeEvent(QResizeEvent *event) override; | void resizeEvent(QResizeEvent *event) override; | ||||
| private: | private: | ||||
| // 按页面尺寸缩放视图,保证完整画布保持可见 | |||||
| void fitCurrentPage(); | void fitCurrentPage(); | ||||
| // 将当前编辑开关同步到所有控件图元 | |||||
| void updateItemInteractions(); | void updateItemInteractions(); | ||||
| // 将场景选择转换为控件标识并通知属性面板 | |||||
| void handleSelectionChanged(); | void handleSelectionChanged(); | ||||
| // 将图元拖动后的坐标提交给 HMI 编辑服务 | |||||
| void handleControlMoved(const std::string &control_id, const QPointF &position); | void handleControlMoved(const std::string &control_id, const QPointF &position); | ||||
| // 在运行态处理按钮点击或数值输入激活 | |||||
| void handleControlActivated(const std::string &control_id); | void handleControlActivated(const std::string &control_id); | ||||
| // 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 | |||||
| HmiEditorService &editor_service_; | HmiEditorService &editor_service_; | ||||
| // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | |||||
| HmiRuntimeService &runtime_service_; | HmiRuntimeService &runtime_service_; | ||||
| // 持有所有页面图元和页面边框 | |||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| // 当前画布投影的页面标识 | |||||
| std::string page_id_; | std::string page_id_; | ||||
| // 控制是否允许拖动和编辑控件 | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| // 控制是否刷新运行值并响应运行态控件操作 | |||||
| bool runtime_active_ = false; | bool runtime_active_ = false; | ||||
| }; | }; | ||||
| @@ -134,10 +134,10 @@ MainWindow::MainWindow( | |||||
| hmi_runtime_service_(hmi_runtime_service) | hmi_runtime_service_(hmi_runtime_service) | ||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| configureAppearance(); | |||||
| configureActions(); | |||||
| configurePropertyEditor(); | |||||
| configureHmiEditor(); | |||||
| configureAppearance(); // 窗口样式、布局、状态栏标签初始化,纯视觉代码,无业务逻辑 | |||||
| configureActions(); // 创建所有菜单 Action、工具栏按钮,绑定点击槽函数 | |||||
| configurePropertyEditor(); // 动态构建属性表单:ID、文本、坐标宽高、寄存器区域 (M/D)、地址索引 + 应用按钮 | |||||
| configureHmiEditor(); // 创建画布控件HmiEditorWidget嵌入主窗口,绑定画布信号:选中控件、控件拖动修改、编辑器报错;同时启动运行刷新定时器 | |||||
| hmi_editor_service_.ensureDefaultPage(); | hmi_editor_service_.ensureDefaultPage(); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| updateModeUi(tr("系统已进入编辑态")); | updateModeUi(tr("系统已进入编辑态")); | ||||
| @@ -290,12 +290,14 @@ void MainWindow::configureHmiEditor() | |||||
| connect(hmi_editor_widget_, &HmiEditorWidget::editorError, | connect(hmi_editor_widget_, &HmiEditorWidget::editorError, | ||||
| this, | this, | ||||
| [this](const QString &message) { statusBar()->showMessage(message, 5000); }); | [this](const QString &message) { statusBar()->showMessage(message, 5000); }); | ||||
| // 运行态定时投影仓库最新值,编辑态不触发寄存器读取 | |||||
| runtime_refresh_timer_ = new QTimer(this); | runtime_refresh_timer_ = new QTimer(this); | ||||
| runtime_refresh_timer_->setInterval(150); | runtime_refresh_timer_->setInterval(150); | ||||
| connect(runtime_refresh_timer_, &QTimer::timeout, | connect(runtime_refresh_timer_, &QTimer::timeout, | ||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| // 离线 / 联机运行模式 | |||||
| if (runtime_mode_service_.mode() != ApplicationMode::Editing) | if (runtime_mode_service_.mode() != ApplicationMode::Editing) | ||||
| { | { | ||||
| hmi_editor_widget_->refreshRuntimeValues(); | hmi_editor_widget_->refreshRuntimeValues(); | ||||
| @@ -352,6 +354,7 @@ void MainWindow::configurePropertyEditor() | |||||
| void MainWindow::refreshProjectUi() | void MainWindow::refreshProjectUi() | ||||
| { | { | ||||
| // 工程树只展示模型摘要,画布内容由 HmiEditorWidget 单独投影 | |||||
| const std::string current_page_id = hmi_editor_service_.firstPageId(); | const std::string current_page_id = hmi_editor_service_.firstPageId(); | ||||
| if (hmi_editor_widget_ != nullptr) | if (hmi_editor_widget_ != nullptr) | ||||
| { | { | ||||
| @@ -376,14 +379,23 @@ void MainWindow::refreshProjectUi() | |||||
| ui_->projectTree->expandAll(); | ui_->projectTree->expandAll(); | ||||
| } | } | ||||
| // 根据控件ID加载控件属性到右侧属性面板 | |||||
| void MainWindow::showControlProperties(const std::string &control_id) | void MainWindow::showControlProperties(const std::string &control_id) | ||||
| { | { | ||||
| // 更新窗口全局状态:记录当前选中控件ID | |||||
| selected_control_id_ = control_id; | selected_control_id_ = control_id; | ||||
| // 在第一页画面中,根据ID查找目标控件,获取只读模型指针 | |||||
| const HmiControl *control = hmi_editor_service_.findControl( | const HmiControl *control = hmi_editor_service_.findControl( | ||||
| hmi_editor_service_.firstPageId(), control_id); | hmi_editor_service_.firstPageId(), control_id); | ||||
| // 判断是否查询到有效控件 | |||||
| const bool has_control = control != nullptr; | const bool has_control = control != nullptr; | ||||
| // 更新属性面板顶部状态标签,显示选中控件ID或提示未选择 | |||||
| ui_->selectionValueLabel->setText( | ui_->selectionValueLabel->setText( | ||||
| has_control ? fromUtf8(control->id) : tr("未选择")); | has_control ? fromUtf8(control->id) : tr("未选择")); | ||||
| // 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑 | |||||
| for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_), | for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_), | ||||
| static_cast<QWidget *>(control_text_edit_), | static_cast<QWidget *>(control_text_edit_), | ||||
| static_cast<QWidget *>(control_x_spin_box_), | static_cast<QWidget *>(control_x_spin_box_), | ||||
| @@ -396,21 +408,33 @@ void MainWindow::showControlProperties(const std::string &control_id) | |||||
| { | { | ||||
| widget->setEnabled(has_control); | widget->setEnabled(has_control); | ||||
| } | } | ||||
| // 未选中有效控件,无需填充属性,直接退出 | |||||
| if (!has_control) | if (!has_control) | ||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 将控件基础信息填充至UI输入框 | |||||
| control_id_edit_->setText(fromUtf8(control->id)); | control_id_edit_->setText(fromUtf8(control->id)); | ||||
| control_text_edit_->setText(fromUtf8(control->text)); | control_text_edit_->setText(fromUtf8(control->text)); | ||||
| // 控件位置坐标 | |||||
| control_x_spin_box_->setValue(control->bounds.x); | control_x_spin_box_->setValue(control->bounds.x); | ||||
| control_y_spin_box_->setValue(control->bounds.y); | control_y_spin_box_->setValue(control->bounds.y); | ||||
| // 控件宽高尺寸 | |||||
| control_width_spin_box_->setValue(control->bounds.width); | control_width_spin_box_->setValue(control->bounds.width); | ||||
| control_height_spin_box_->setValue(control->bounds.height); | control_height_spin_box_->setValue(control->bounds.height); | ||||
| // 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区 | |||||
| const RegisterArea default_area = usesMAddress(control->type) | const RegisterArea default_area = usesMAddress(control->type) | ||||
| ? RegisterArea::M : RegisterArea::D; | ? RegisterArea::M : RegisterArea::D; | ||||
| // 优先使用控件已绑定的寄存器区域;无绑定则使用类型对应的默认区域 | |||||
| const RegisterArea area = control->binding.has_value() | const RegisterArea area = control->binding.has_value() | ||||
| ? control->binding->area() : default_area; | ? control->binding->area() : default_area; | ||||
| // 同步寄存器区域下拉框选中项(0=M,1=D,与combo初始化顺序严格对应) | |||||
| binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); | binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); | ||||
| // 填充寄存器地址索引;无绑定时默认地址为0 | |||||
| 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); | ||||
| } | } | ||||
| @@ -458,6 +482,7 @@ void MainWindow::applySelectedControlProperties() | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 从现有控件复制不可见属性,避免属性面板覆盖扩展配置 | |||||
| HmiControl control = *old_control; | HmiControl control = *old_control; | ||||
| control.id = toUtf8(control_id_edit_->text()); | control.id = toUtf8(control_id_edit_->text()); | ||||
| control.text = toUtf8(control_text_edit_->text()); | control.text = toUtf8(control_text_edit_->text()); | ||||
| @@ -566,6 +591,7 @@ void MainWindow::showProjectResult( | |||||
| void MainWindow::requestMode(ApplicationMode requested_mode) | void MainWindow::requestMode(ApplicationMode requested_mode) | ||||
| { | { | ||||
| // UI 仅转发模式意图,合法性由服务层和领域状态机决定 | |||||
| ModeTransitionResult result; | ModeTransitionResult result; | ||||
| switch (requested_mode) | switch (requested_mode) | ||||
| { | { | ||||
| @@ -605,6 +631,7 @@ void MainWindow::updateModeUi(const QString &message) | |||||
| const ApplicationMode mode = runtime_mode_service_.mode(); | const ApplicationMode mode = runtime_mode_service_.mode(); | ||||
| const ModePolicy policy = runtime_mode_service_.policy(); | const ModePolicy policy = runtime_mode_service_.policy(); | ||||
| restoreCurrentModeAction(); | restoreCurrentModeAction(); | ||||
| // 将同一份模式策略同步到所有可编辑入口,避免只禁用部分操作 | |||||
| ui_->projectDock->setEnabled(policy.allowsProjectEditing); | ui_->projectDock->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->propertiesDock->setEnabled(policy.allowsProjectEditing); | ui_->propertiesDock->setEnabled(policy.allowsProjectEditing); | ||||
| hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | ||||
| @@ -646,6 +673,7 @@ void MainWindow::updateModeUi(const QString &message) | |||||
| ui_->outputList->scrollToBottom(); | ui_->outputList->scrollToBottom(); | ||||
| } | } | ||||
| // 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态 | |||||
| void MainWindow::restoreCurrentModeAction() | void MainWindow::restoreCurrentModeAction() | ||||
| { | { | ||||
| ui_->editingModeAction->setChecked(runtime_mode_service_.mode() | ui_->editingModeAction->setChecked(runtime_mode_service_.mode() | ||||
| @@ -65,19 +65,33 @@ public: | |||||
| ~MainWindow() override; | ~MainWindow() override; | ||||
| private: | private: | ||||
| // 配置菜单和工具栏动作 | |||||
| void configureActions(); | void configureActions(); | ||||
| // 配置主窗口外观和状态栏 | |||||
| void configureAppearance(); | void configureAppearance(); | ||||
| // 创建并连接 HMI 编辑画布 | |||||
| void configureHmiEditor(); | void configureHmiEditor(); | ||||
| // 创建并连接控件属性编辑表单 | |||||
| void configurePropertyEditor(); | void configurePropertyEditor(); | ||||
| // 刷新工程树和当前 HMI 页面信息 | |||||
| void refreshProjectUi(); | void refreshProjectUi(); | ||||
| // 显示指定 HMI 控件的可编辑属性 | |||||
| void showControlProperties(const std::string &control_id); | void showControlProperties(const std::string &control_id); | ||||
| // 向当前页面添加指定类型的 HMI 控件 | |||||
| void addHmiControl(HmiControlType type); | void addHmiControl(HmiControlType type); | ||||
| // 删除当前选中的 HMI 控件 | |||||
| void deleteSelectedControl(); | void deleteSelectedControl(); | ||||
| // 将属性表单内容应用到当前选中控件 | |||||
| void applySelectedControlProperties(); | void applySelectedControlProperties(); | ||||
| // 创建新的工程并刷新编辑界面 | |||||
| void createNewProject(); | void createNewProject(); | ||||
| // 保存当前工程到已关联的路径 | |||||
| void saveProject(); | void saveProject(); | ||||
| // 将当前工程保存到用户指定的路径 | |||||
| void saveProjectAs(); | void saveProjectAs(); | ||||
| // 从用户指定的路径加载工程 | |||||
| void loadProject(); | void loadProject(); | ||||
| // 在状态栏和输出面板显示工程操作结果 | |||||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | void showProjectResult(const QString &action, const QString &message, bool succeeded); | ||||
| /** | /** | ||||
| @@ -23,6 +23,7 @@ void require(bool condition, const std::string &message) | |||||
| void testRegisterAddressBoundaries() | void testRegisterAddressBoundaries() | ||||
| { | { | ||||
| // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | |||||
| require(RegisterAddress{RegisterArea::M, 0}.isValid(), | require(RegisterAddress{RegisterArea::M, 0}.isValid(), | ||||
| "M0 must be valid"); | "M0 must be valid"); | ||||
| require(RegisterAddress{RegisterArea::D, 4000}.isValid(), | require(RegisterAddress{RegisterArea::D, 4000}.isValid(), | ||||
| @@ -37,6 +38,7 @@ void testRegisterAddressBoundaries() | |||||
| void testRegisterRepositorySeparatesAreas() | void testRegisterRepositorySeparatesAreas() | ||||
| { | { | ||||
| // 验证离线仓库不会把 M 位和 D 字交叉解释 | |||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| const RegisterAddress m0{RegisterArea::M, 0}; | const RegisterAddress m0{RegisterArea::M, 0}; | ||||
| const RegisterAddress d0{RegisterArea::D, 0}; | const RegisterAddress d0{RegisterArea::D, 0}; | ||||
| @@ -54,6 +56,7 @@ void testRegisterRepositorySeparatesAreas() | |||||
| Project makeValidProject() | Project makeValidProject() | ||||
| { | { | ||||
| // 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线 | |||||
| HmiControl start_button; | HmiControl start_button; | ||||
| start_button.id = "start-button"; | start_button.id = "start-button"; | ||||
| start_button.type = HmiControlType::Button; | start_button.type = HmiControlType::Button; | ||||
| @@ -92,6 +95,7 @@ Project makeValidProject() | |||||
| void testLogicNodeConfigurationBoundaries() | void testLogicNodeConfigurationBoundaries() | ||||
| { | { | ||||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | |||||
| LogicNode contact; | LogicNode contact; | ||||
| contact.id = "contact"; | contact.id = "contact"; | ||||
| contact.config = ContactNodeConfig{ | contact.config = ContactNodeConfig{ | ||||
| @@ -115,6 +119,7 @@ void testLogicNodeConfigurationBoundaries() | |||||
| void testModelsValidateBindingsAndIdentifiers() | void testModelsValidateBindingsAndIdentifiers() | ||||
| { | { | ||||
| // 聚合验证必须拒绝错误绑定、重复标识和越界控件 | |||||
| Project project = makeValidProject(); | Project project = makeValidProject(); | ||||
| require(project.validate(), "valid project model must pass validation"); | require(project.validate(), "valid project model must pass validation"); | ||||
| @@ -141,6 +146,7 @@ void testModelsValidateBindingsAndIdentifiers() | |||||
| void testRuntimeStateBoundaries() | void testRuntimeStateBoundaries() | ||||
| { | { | ||||
| // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件 | |||||
| RuntimeState state; | RuntimeState state; | ||||
| require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); | require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); | ||||
| require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); | require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); | ||||
| @@ -167,6 +173,7 @@ int main() | |||||
| { | { | ||||
| try | try | ||||
| { | { | ||||
| // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程 | |||||
| testRegisterAddressBoundaries(); | testRegisterAddressBoundaries(); | ||||
| testRegisterRepositorySeparatesAreas(); | testRegisterRepositorySeparatesAreas(); | ||||
| testLogicNodeConfigurationBoundaries(); | testLogicNodeConfigurationBoundaries(); | ||||
| @@ -14,6 +14,7 @@ namespace { | |||||
| class TestProjectStorage final : public ProjectStorage | class TestProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| // 编辑服务测试只关注内存模型变更,不依赖真实文件系统 | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | ProjectSaveResult save(const Project &, const std::string &) override | ||||
| { | { | ||||
| return {true, ProjectStorageError::None, {}}; | return {true, ProjectStorageError::None, {}}; | ||||
| @@ -35,6 +36,7 @@ void require(bool condition, const std::string &message) | |||||
| void testControlEditing() | void testControlEditing() | ||||
| { | { | ||||
| // 覆盖控件创建、移动、绑定校验、重命名冲突和删除流程 | |||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| ProjectService project_service(storage); | ProjectService project_service(storage); | ||||
| HmiEditorService service(project_service); | HmiEditorService service(project_service); | ||||
| @@ -81,6 +83,7 @@ void testControlEditing() | |||||
| void testRuntimeUsesRegisterRepository() | void testRuntimeUsesRegisterRepository() | ||||
| { | { | ||||
| // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 | |||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| HmiRuntimeService runtime_service(repository); | HmiRuntimeService runtime_service(repository); | ||||
| @@ -123,6 +126,7 @@ int main() | |||||
| { | { | ||||
| try | try | ||||
| { | { | ||||
| // 编辑和运行场景分别验证服务层两条独立职责 | |||||
| testControlEditing(); | testControlEditing(); | ||||
| testRuntimeUsesRegisterRepository(); | testRuntimeUsesRegisterRepository(); | ||||
| } | } | ||||
| @@ -22,6 +22,7 @@ namespace { | |||||
| class TestProjectStorage final : public ProjectStorage | class TestProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响 | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | ProjectSaveResult save(const Project &, const std::string &) override | ||||
| { | { | ||||
| return {true, ProjectStorageError::None, {}}; | return {true, ProjectStorageError::None, {}}; | ||||
| @@ -44,6 +45,7 @@ void require(bool condition, const std::string &message) | |||||
| template<typename ObjectType> | template<typename ObjectType> | ||||
| ObjectType *requiredChild(MainWindow &window, const char *name) | ObjectType *requiredChild(MainWindow &window, const char *name) | ||||
| { | { | ||||
| // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息 | |||||
| ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name)); | ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name)); | ||||
| require(child != nullptr, std::string("missing UI object ") + name); | require(child != nullptr, std::string("missing UI object ") + name); | ||||
| return child; | return child; | ||||
| @@ -51,6 +53,7 @@ ObjectType *requiredChild(MainWindow &window, const char *name) | |||||
| void testModeActionsControlEditingAvailability() | void testModeActionsControlEditingAvailability() | ||||
| { | { | ||||
| // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择 | |||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| ProjectService project_service(storage); | ProjectService project_service(storage); | ||||
| HmiEditorService editor_service(project_service); | HmiEditorService editor_service(project_service); | ||||
| @@ -134,6 +137,7 @@ void testModeActionsControlEditingAvailability() | |||||
| int main(int argc, char *argv[]) | int main(int argc, char *argv[]) | ||||
| { | { | ||||
| // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行 | |||||
| qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); | qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); | ||||
| QApplication application(argc, argv); | QApplication application(argc, argv); | ||||
| @@ -22,6 +22,7 @@ void require(bool condition, const std::string &message) | |||||
| Project makeExampleProject() | Project makeExampleProject() | ||||
| { | { | ||||
| // 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本 | |||||
| HmiControl start_button; | HmiControl start_button; | ||||
| start_button.id = "start-button"; | start_button.id = "start-button"; | ||||
| start_button.type = HmiControlType::Button; | start_button.type = HmiControlType::Button; | ||||
| @@ -98,6 +99,7 @@ Project makeExampleProject() | |||||
| void writeText(const QString &path, const QByteArray &content) | void writeText(const QString &path, const QByteArray &content) | ||||
| { | { | ||||
| // 直接写入故障样本文件,以验证加载失败时的保护行为 | |||||
| QFile file(path); | QFile file(path); | ||||
| require(file.open(QIODevice::WriteOnly), "test file must be writable"); | require(file.open(QIODevice::WriteOnly), "test file must be writable"); | ||||
| require(file.write(content) == content.size(), "test file must be written completely"); | require(file.write(content) == content.size(), "test file must be written completely"); | ||||
| @@ -112,6 +114,7 @@ QByteArray readBytes(const QString &path) | |||||
| void testEmptyProjectRoundTrip() | void testEmptyProjectRoundTrip() | ||||
| { | { | ||||
| // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑 | |||||
| QTemporaryDir directory; | QTemporaryDir directory; | ||||
| require(directory.isValid(), "temporary directory must be valid"); | require(directory.isValid(), "temporary directory must be valid"); | ||||
| @@ -136,6 +139,7 @@ void testEmptyProjectRoundTrip() | |||||
| void testExampleProjectRoundTrip() | void testExampleProjectRoundTrip() | ||||
| { | { | ||||
| // 验证各层嵌套字段往返后保持不变且序列化结果稳定 | |||||
| QTemporaryDir directory; | QTemporaryDir directory; | ||||
| require(directory.isValid(), "temporary directory must be valid"); | require(directory.isValid(), "temporary directory must be valid"); | ||||
| @@ -186,6 +190,7 @@ void testExampleProjectRoundTrip() | |||||
| void testInvalidFiles() | void testInvalidFiles() | ||||
| { | { | ||||
| // 非法文件必须被拒绝,并且不得覆盖服务中当前工程 | |||||
| QTemporaryDir directory; | QTemporaryDir directory; | ||||
| require(directory.isValid(), "temporary directory must be valid"); | require(directory.isValid(), "temporary directory must be valid"); | ||||
| @@ -219,6 +224,7 @@ void testInvalidFiles() | |||||
| void testServiceStateAndSaveErrors() | void testServiceStateAndSaveErrors() | ||||
| { | { | ||||
| // 保存路径和修改标记只在成功持久化后更新 | |||||
| QTemporaryDir directory; | QTemporaryDir directory; | ||||
| require(directory.isValid(), "temporary directory must be valid"); | require(directory.isValid(), "temporary directory must be valid"); | ||||
| @@ -252,6 +258,7 @@ int main() | |||||
| { | { | ||||
| try | try | ||||
| { | { | ||||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | |||||
| testEmptyProjectRoundTrip(); | testEmptyProjectRoundTrip(); | ||||
| testExampleProjectRoundTrip(); | testExampleProjectRoundTrip(); | ||||
| testInvalidFiles(); | testInvalidFiles(); | ||||
| @@ -16,6 +16,7 @@ void require(bool condition, const std::string &message) | |||||
| void testModeTransitions() | void testModeTransitions() | ||||
| { | { | ||||
| // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | |||||
| RuntimeModeService service; | RuntimeModeService service; | ||||
| require(service.mode() == ApplicationMode::Editing, | require(service.mode() == ApplicationMode::Editing, | ||||
| @@ -54,6 +55,7 @@ int main() | |||||
| { | { | ||||
| try | try | ||||
| { | { | ||||
| // 运行模式只有这一组状态机边界测试 | |||||
| testModeTransitions(); | testModeTransitions(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||