From ca12b7427c9d4a61a1b24b374cc0ce74c848603e Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Wed, 2 Sep 2026 09:16:38 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E4=B8=8E=E4=BB=A3=E7=A0=81=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plc_communication_service.cpp | 1 + app/src/services/logic_editor_service.cpp | 12 +- app/src/services/logic_editor_service.h | 11 +- .../services/online_logic_monitor_service.cpp | 10 +- .../services/online_logic_monitor_service.h | 13 +- app/src/services/runtime_mode_service.cpp | 5 +- app/src/services/software_logic_executor.cpp | 83 ++++++-- app/src/services/software_logic_executor.h | 7 +- app/src/ui/hmi_editor_widget.cpp | 104 ++++++++++ app/src/ui/logic_editor_widget.cpp | 189 +++++++++++++++++- app/src/ui/logic_editor_widget.h | 14 +- app/src/ui/main_window.cpp | 169 +++++++++++++++- app/src/ui/runtime_panel_controller.cpp | 5 +- docs/ai/handoff.md | 9 +- .../工程新建保存加载与JSON说明.md | 171 ++++++++++++++++ docs/代码功能阅读清单.md | 3 +- 16 files changed, 758 insertions(+), 48 deletions(-) create mode 100644 docs/二次开发/工程新建保存加载与JSON说明.md diff --git a/app/src/infrastructure/plc_communication_service.cpp b/app/src/infrastructure/plc_communication_service.cpp index bc5bc7d..1fa7972 100644 --- a/app/src/infrastructure/plc_communication_service.cpp +++ b/app/src/infrastructure/plc_communication_service.cpp @@ -573,6 +573,7 @@ void PlcCommunicationService::pollNextBlock() } if (poll_cycle_completed) { + // 此信号表示本轮全部读块已更新,真机本地轨迹只能在这个一致快照边界上推算 emit pollCycleCompleted(); if (poll_cycle_completed_callback_) { diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 8cb13a5..f4e2c82 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -1386,6 +1386,7 @@ LogicEditorResult LogicEditorService::setHorizontalWireRange( int last_column, bool connected) { + // 鼠标允许从右向左拖动,先统一成从小列号到大列号 if (first_column > last_column) { std::swap(first_column, last_column); @@ -1410,6 +1411,7 @@ LogicEditorResult LogicEditorService::setWireCells( const std::vector> &cells, bool connected) { + // 先完成全部只读检查,再接触可编辑工程,避免改到一半才发现目标无效 const ControlLogic *logic = findLogic(logic_id); if (logic == nullptr) { @@ -1445,6 +1447,7 @@ LogicEditorResult LogicEditorService::setWireCells( }; if (!std::any_of(cells.cbegin(), cells.cend(), should_change)) { + // 目标已经是需要的状态,不创建没有实际内容的撤销记录 return { true, LogicEditorError::None, @@ -1452,6 +1455,7 @@ LogicEditorResult LogicEditorService::setWireCells( cells.front().first}; } + // before 是整次手势开始前的快照,后面所有网格共用这一份撤销记录 HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); @@ -1462,6 +1466,7 @@ LogicEditorResult LogicEditorService::setWireCells( ->cells[static_cast(position.second)]; if (cell.kind == LadderCellKind::Node) { + // 触点和比较指令属于业务节点,鼠标画线不能覆盖它们 continue; } cell.kind = connected ? LadderCellKind::Wire : LadderCellKind::Gap; @@ -1470,6 +1475,7 @@ LogicEditorResult LogicEditorService::setWireCells( std::string error; if (!editable->validateStructure(project_service_.projectLimits(), &error)) { + // 整体校验失败时恢复快照,所以不会留下只改了一部分的横线 rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, error); } @@ -1644,6 +1650,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( } if (first > last) { + // 允许用户从下往上拖动,内部仍按视觉顺序从上向下处理 std::swap(first, last); } @@ -1669,6 +1676,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( } if (!changed) { + // 整段竖线已经符合目标状态,不修改工程也不生成空撤销记录 return { true, LogicEditorError::None, @@ -1676,6 +1684,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( first_rung_id}; } + // 所有相邻行连接共用一个快照,因此一根长竖线可以一次撤销 HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); @@ -1695,7 +1704,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( }); if (connected && found == editable->verticalConnections.end()) { - // 每对相邻行保存一段竖线,跨多行的长竖线由多段组成 + // 数据只保存相邻两行的一小段,连续小段在画布上组成一根长竖线 editable->verticalConnections.push_back({ makeUniqueId(*editable, "vertical"), upper_id, @@ -1715,6 +1724,7 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( } if (!editable->validateStructure(project_service_.projectLimits(), &error)) { + // 数量或结构校验失败时恢复全部连接,不保留半根竖线 rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, error); } diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index ccdcc43..6a10539 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -260,7 +260,9 @@ public: const LogicEditCursor &cursor, const LogicNodeConfig &config, bool configured = false); - // 设置同一梯级内一段连续水平线的连接状态 + // 设置同一行从 first_column 到 last_column 的整段横线 + // connected 为 true 表示把空格变成横线,false 表示把横线变回空格 + // 范围中的条件节点保持原样,整段操作只产生一条撤销记录 LogicEditorResult setHorizontalWireRange( const std::string &logic_id, const std::string &rung_id, @@ -271,7 +273,8 @@ public: LogicEditResult applyWireAndAdvance( const std::string &logic_id, const LogicEditCursor &cursor); - // 一次设置多个条件单元的水平线状态 + // 横线范围 API 的实际提交入口,先检查全部网格,再统一修改和校验 + // 任一位置无效或最终结构非法时,所有网格都保持修改前的状态 LogicEditorResult setWireCells( const std::string &logic_id, const std::vector> &cells, @@ -292,7 +295,9 @@ public: const std::string &logic_id, const std::string &upper_rung_id, int column_boundary); - // 设置一段连续梯级范围内的垂直连接状态 + // 在 first_rung_id 和 last_rung_id 之间设置同一列边界上的竖线 + // 跨越多行时会拆成多条“相邻两行连接”,视觉上仍是一根连续长竖线 + // connected 为 true 表示建立连接,false 表示删除连接 LogicEditorResult setVerticalConnectionRange( const std::string &logic_id, const std::string &first_rung_id, diff --git a/app/src/services/online_logic_monitor_service.cpp b/app/src/services/online_logic_monitor_service.cpp index a57ba1b..2b43349 100644 --- a/app/src/services/online_logic_monitor_service.cpp +++ b/app/src/services/online_logic_monitor_service.cpp @@ -95,7 +95,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( state_ = OnlineLogicMonitorState::Running; emit stateChanged(); - // 启动时立即复制一次 PLC 缓存并扫描,确保首份轨迹真实可用 + // 启动时立即走一遍“PLC 缓存 -> 临时仓库 -> 本地扫描”,进入真机大屏时即可显示首份轨迹 const LogicScanResult first_scan = executeOnce(); if (!first_scan.succeeded) { @@ -139,14 +139,15 @@ LogicScanResult OnlineLogicMonitorService::executeOnce() {}}; } - // 扫描前只复制梯形图实际引用的 M/D,绝不把 PLC 仓库直接交给执行器 + // 输入阶段只复制梯形图引用的 M/D,避免 SoftwareLogicExecutor 的写操作触达 PLC 仓库 const LogicScanResult copied = copyPlcSnapshot(); if (!copied.succeeded) { enterFault(copied); return copied; } - // 线圈和数据指令只写临时仓库,扫描结果仅用于生成大屏亮线轨迹 + // 计算阶段允许前面网络的临时输出供后面网络读取,但这些值只存在于 working_repository_ + // trace_snapshot_ 是编程器根据缓存推算的显示结果,不是 PLC 内部程序返回的在线轨迹 const LogicScanResult result = executor_.executeScan( logic_snapshot_, working_repository_, &trace_snapshot_); if (!result.succeeded) @@ -155,6 +156,7 @@ LogicScanResult OnlineLogicMonitorService::executeOnce() return result; } ++successful_scan_count_; + // 输出阶段只发布轨迹更新信号,由 RuntimePanelController 转交给梯形图画布 emit scanCompleted(); return result; } @@ -186,7 +188,7 @@ const LogicTraceSnapshot &OnlineLogicMonitorService::traceSnapshot() const // 将真实 PLC 读回缓存复制到本轮独立的临时虚拟仓库 LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot() { - // 每轮从真实读回缓存重新开始,本地输出不会跨轮污染 PLC 数据 + // 每轮先清空上轮临时输出,再从最新 PLC 缓存重新建立本轮输入快照 working_repository_.clear(); for (const RegisterAddress &address : referenced_addresses_) { diff --git a/app/src/services/online_logic_monitor_service.h b/app/src/services/online_logic_monitor_service.h index 6b6dced..3cc49fa 100644 --- a/app/src/services/online_logic_monitor_service.h +++ b/app/src/services/online_logic_monitor_service.h @@ -33,7 +33,8 @@ struct OnlineLogicMonitorStartResult LogicScanResult detail; // 执行器返回的详细错误和定位信息 }; -// 使用 PLC 读回缓存推算本地梯形图轨迹,所有输出只写临时仓库 +// 使用 PLC 读回缓存推算编程器内梯形图轨迹,所有输出只写临时仓库 +// 本服务既不读取 PLC 内部程序,也不把推算轨迹当作 PLC 内部真实执行轨迹 class OnlineLogicMonitorService final : public QObject { Q_OBJECT @@ -44,11 +45,11 @@ public: RegisterRepository &plc_repository, QObject *parent = nullptr); - // 校验并保存逻辑快照,随后立即使用 PLC 缓存推算首份轨迹 + // 输入编程器工程中的逻辑,校验并保存快照,再用已完成首读的 PLC 缓存生成首份轨迹 OnlineLogicMonitorStartResult start(const std::vector &logics); // 停止推算并清除会话快照和轨迹 void stop(); - // 复制最新 PLC 缓存并在临时仓库执行一轮本地扫描 + // 每轮 PLC 轮询完成后调用:重新复制缓存,在临时仓库扫描并发布新的本地轨迹 LogicScanResult executeOnce(); // 返回当前真机本地轨迹生命周期状态 @@ -67,13 +68,13 @@ signals: void scanCompleted(); private: - // 将梯形图实际引用的 PLC 缓存值复制到临时仓库 + // 将梯形图实际引用的 PLC 缓存值复制到临时仓库,隔离执行器写入与真实 PLC LogicScanResult copyPlcSnapshot(); // 保存错误、清理边沿状态并切换到故障态 void enterFault(const LogicScanResult &error); - RegisterRepository &plc_repository_; // 不拥有的 PLC 读回缓存仓库 - VirtualRegisterRepository working_repository_; // 隔离本地输出的单轮临时仓库 + RegisterRepository &plc_repository_; // 只作为每轮输入快照来源,不直接交给执行器 + VirtualRegisterRepository working_repository_; // 接收本地线圈和数据指令输出,生命周期仅限本地推算 SoftwareLogicExecutor executor_; // 校验逻辑并推算本地运行轨迹 std::vector logic_snapshot_; // 本次会话固定使用的逻辑快照 std::vector referenced_addresses_; // 梯形图实际引用的去重地址 diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 01a6d10..e7a0528 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -131,7 +131,7 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning() } if (!hmi_only_runtime_) { - // 普通编程器保存逻辑快照并启动 PLC 缓存副本上的轨迹推算 + // 普通编程器用“工程逻辑 + PLC 缓存输入”启动本地推算,不读取 PLC 内部程序 const OnlineLogicMonitorStartResult start_result = online_logic_monitor_service_.start( project_service_.project().controlLogics); @@ -310,7 +310,8 @@ void RuntimeModeService::configurePlc( && online_logic_monitor_service_.state() == OnlineLogicMonitorState::Running) { - // 每轮 PLC 地址全部读完后,用最新缓存副本重新推算一次本地轨迹 + // 只有全部轮询块成功完成一轮后才推算,避免用一半新值和一半旧值生成轨迹 + // executeOnce 内部会复制到临时仓库,本地线圈和数据指令不会写回 PLC const LogicScanResult result = online_logic_monitor_service_.executeOnce(); if (!result.succeeded diff --git a/app/src/services/software_logic_executor.cpp b/app/src/services/software_logic_executor.cpp index 9072925..b4479d6 100644 --- a/app/src/services/software_logic_executor.cpp +++ b/app/src/services/software_logic_executor.cpp @@ -189,39 +189,52 @@ void SoftwareLogicExecutor::resetRuntime() } // 按工程顺序执行一轮完整的软件逻辑扫描 +// 按工程顺序执行一轮完整的梯形图软件逻辑扫描(PLC周期仿真) LogicScanResult SoftwareLogicExecutor::executeScan( const std::vector &logics, RegisterRepository &repository, LogicTraceSnapshot *trace) { + + // 第一步:校验整套逻辑,检查语法、重复输出冲突等问题 const LogicScanResult validation = validate(logics); if (!validation.succeeded) { + // 校验失败直接返回错误,不再执行扫描 return validation; } - // 每轮从空轨迹开始,防止已删除或未执行的节点残留旧状态 + // 每轮扫描前清空轨迹快照,清除上一轮仿真残留状态,防止旧数据干扰界面仿真高亮 if (trace != nullptr) { trace->clear(); } - // 互相没有竖线连接的行仍按先后顺序扫描,保持原有网络间可见性 + + // 遍历工程内所有控制逻辑(多套独立梯形图),按工程顺序依次扫描 + // 互相没有竖线连接的梯级组仍保持先后扫描顺序,保证网络间状态可见性 for (const ControlLogic &logic : logics) { + // 跳过被禁用的逻辑块,不参与本轮扫描 if (!logic.enabled) { continue; } + + // 获取当前逻辑对应的轨迹存储对象;trace为空时不记录仿真轨迹 LogicTraceValues *logic_trace = trace == nullptr ? nullptr : &trace->logicValues[logic.id]; + std::size_t group_start = 0U; + // 拆分【竖线连通梯级组】:把连续、存在垂直连线的梯级打包成一组,分组执行电源传播 while (group_start < logic.rungs.size()) { - std::size_t group_end = group_start; + std::size_t group_end = group_start; // 当前连通组的最后一行梯级下标,初始为首行 + // 向后查找:连续梯级之间存在垂直竖线,则合并到同一个连通组,直到竖线断开 while (group_end + 1U < logic.rungs.size()) { - const std::string &upper_id = logic.rungs[group_end].id; - const std::string &lower_id = logic.rungs[group_end + 1U].id; + const std::string &upper_id = logic.rungs[group_end].id; // 当前梯级 + const std::string &lower_id = logic.rungs[group_end + 1U].id; // 下一梯级 + // 判断相邻两行梯级之间是否存在垂直连线 const bool connected = std::any_of( logic.verticalConnections.cbegin(), logic.verticalConnections.cend(), @@ -232,24 +245,32 @@ LogicScanResult SoftwareLogicExecutor::executeScan( }); if (!connected) { + // 相邻梯级无竖线,连通组终止 break; } ++group_end; } - // 每个竖线连通组按列传播电源,组内行共享对应列边界的导通状态 + // 当前连通组的梯级总数,同一连通组一起逐列从左向右传播电源 const std::size_t group_size = group_end - group_start + 1U; + // power数组:保存本组每一行梯级【当前列入口】的带电状态,初始全部带电(母线输入) std::vector power(group_size, true); + + // 逐列遍历梯形图(从左向右传播电源,boundary代表当前处理的列边界) for (int boundary = 0; boundary <= ProjectLimits::kMaximumConditionColumns; ++boundary) { - // 用并查集合并当前列边界上的竖线,避免反复沿上下支路递归查找 + // ========== 并查集:合并当前列边界上所有垂直竖线,构建梯级连通分量 ========== + // 父节点数组,用于并查集,每组梯级独立 std::vector parent(group_size); + // 初始化每个梯级的父节点为自己,表示每个梯级最初是独立的连通分量 for (std::size_t index = 0U; index < group_size; ++index) { parent[index] = index; } + + // 查找根节点(并查集find,无路径压缩,够用即可) const auto root = [&parent](std::size_t index) { while (parent[index] != index) @@ -258,6 +279,8 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } return index; }; + + // 合并两个梯级到同一个连通分量(并查集union) const auto unite = [&parent, &root]( std::size_t left, std::size_t right) { @@ -269,13 +292,16 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } }; + // 遍历所有垂直连线,只处理当前列边界boundary上的竖线 for (const VerticalConnection &connection : logic.verticalConnections) { + // 不属于当前列边界的竖线跳过 if (connection.columnBoundary != boundary) { continue; } + // 在当前连通组内找到这条竖线对应的上下两行梯级,执行合并 for (std::size_t row = group_start; row < group_end; ++row) @@ -290,17 +316,21 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } } - // 同一连通分量任意一行带电,当前边界上的全部成员都视为带电 + // ========== 等电位传播:同一连通分量任意一行带电,则整个分量全部带电 ========== std::vector component_power(group_size, false); + // 标记每个连通分量是否带电:分量内任意一行power=true → 整个分量带电 for (std::size_t index = 0U; index < group_size; ++index) { component_power[root(index)] = component_power[root(index)] || power[index]; } + // 更新本组所有梯级带电状态:同一连通分量等电位 for (std::size_t index = 0U; index < group_size; ++index) { power[index] = component_power[root(index)]; } + + // 开启轨迹记录时:保存当前竖线的带电状态,用于界面仿真高亮 if (logic_trace != nullptr) { for (const VerticalConnection &connection @@ -325,38 +355,51 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } } } + + // 到达最大条件列,条件区结束,不再继续向右处理单元格 if (boundary == ProjectLimits::kMaximumConditionColumns) { break; } + // ========== 处理当前列每个单元格:导线/触点求值,更新电源状态向右传递 ========== for (std::size_t local_row = 0U; local_row < group_size; ++local_row) { const LadderRung &rung = logic.rungs[group_start + local_row]; + // 取出当前梯级、当前列的单元格(触点/导线) const LadderCell &cell = rung.cells[ static_cast(boundary)]; + // 单元格入口电源(从左侧传过来的电) const bool input_power = power[local_row]; + // 导线默认导通;Node触点需要单独求值 bool cell_value = cell.kind == LadderCellKind::Wire; LogicScanResult result = success(); + if (cell.kind == LadderCellKind::Node) { - // 条件格是 Node 时读取 M/D,并计算触点或比较条件结果 + // 条件单元格(触点/比较指令):读取寄存器M/D,计算触点通断结果 result = evaluateCondition( logic.id, *cell.node, repository, &cell_value); } + + // 条件求值异常,携带位置信息直接返回错误终止扫描 if (!result.succeeded) { result.logicId = logic.id; result.rungId = rung.id; return result; } + + // 单元格出口电源 = 左侧有电 && 当前单元格导通,电向右传递 power[local_row] = input_power && cell_value; + + // 记录单元格仿真轨迹:输入电、单元格本身状态、输出带电 if (logic_trace != nullptr) { logic_trace->cellValues[cell.id] = cell_value; @@ -372,47 +415,61 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } } - // 同一个竖线连通组先完成条件传播,再按视觉行顺序执行输出 + // ========== PLC扫描原则:全部条件计算完成后,统一执行输出(线圈) ========== + // 同一个竖线连通组,先跑完所有列条件传播,再按梯级顺序执行右侧线圈输出 for (std::size_t local_row = 0U; local_row < group_size; ++local_row) { const LadderRung &rung = logic.rungs[group_start + local_row]; + // 当前梯级最终输出带电状态(条件区计算完毕后的结果) const bool rung_value = power[local_row]; + // 记录梯级带电状态到仿真轨迹 if (logic_trace != nullptr) { logic_trace->rungValues[rung.id] = rung_value; } + + // 本梯级没有输出线圈,跳过 if (!rung.output.has_value()) { continue; } + bool output_value = false; - // 使用这一行最终导通状态执行线圈或数据输出指令 + // 根据梯级导通状态,执行线圈输出逻辑,写入寄存器仓库 LogicScanResult result = executeOutput( *rung.output, rung_value, repository, logic_trace, &output_value); + + // 输出执行异常,携带位置信息返回错误 if (!result.succeeded) { result.logicId = logic.id; result.rungId = rung.id; return result; } + + // 记录线圈节点仿真状态 if (logic_trace != nullptr) { logic_trace->nodeValues[rung.output->id] = output_value; logic_trace->nodePowerValues[rung.output->id] = rung_value; } } + + // 移动起始位置,处理下一个连通梯级组 group_start = group_end + 1U; } } + + // 轨迹快照顶层兼容处理:为了兼容旧版前端视图,顶层只投影第一条启用逻辑的数据 + // 完整多逻辑仿真数据仍然保存在 trace->logicValues 中,不会丢失 if (trace != nullptr) { - // 顶层兼容视图只投影第一条启用逻辑,完整轨迹仍保存在 logicValues 中 const auto first_enabled = std::find_if( logics.cbegin(), logics.cend(), [](const ControlLogic &logic) { return logic.enabled; }); @@ -433,6 +490,8 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } } } + + // 本轮扫描正常执行完毕 return success(); } diff --git a/app/src/services/software_logic_executor.h b/app/src/services/software_logic_executor.h index c8b99ad..42df453 100644 --- a/app/src/services/software_logic_executor.h +++ b/app/src/services/software_logic_executor.h @@ -88,9 +88,12 @@ public: /** * @brief 使用当前 steady_clock 执行一轮完整扫描 * @param logics 按工程顺序扫描的控制逻辑集合 - * @param repository 扫描读取和写入的寄存器仓库 - * @param trace 可选的轨迹输出;非空时会先清空再写入本轮结果 + * @param repository 本轮扫描的读写目标;离线传虚拟仓库,真机推算必须传隔离的临时仓库 + * @param trace 可选的轨迹输出;非空时会先清空再写入本轮节点和导线带电结果 * @return 扫描结果,失败时不会返回部分成功状态作为成功结果 + * + * 执行器不知道当前运行模式,调用方通过 repository 决定输出落点 + * OnlineLogicMonitorService 不得把 PlcRegisterRepository 直接传入此函数 */ LogicScanResult executeScan( const std::vector &logics, diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 4c44f6f..e633f8a 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -47,8 +47,10 @@ constexpr qreal kAlarmPageIndicatorWidth = 34.0; // 将报警时间转换为时分秒显示文字 QString alarmTimeText(const std::chrono::system_clock::time_point &time) { + // duration_cast 丢弃不足一秒的部分,报警列表不显示毫秒 const auto seconds = std::chrono::duration_cast( time.time_since_epoch()).count(); + // fromSecsSinceEpoch 把 Unix 时间戳交给 Qt,再按本机时区格式化 return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss")); } @@ -59,9 +61,12 @@ QString numericValueText(const RegisterNumericValue &value) return std::visit( [](const auto &typed_value) { + // decay_t 去掉 const 和引用,得到 variant 当前值的实际基础类型 using Value = std::decay_t; + // if constexpr 只编译命中的类型分支,不会产生运行时类型判断 if constexpr (std::is_same_v) { + // max_digits10 保证浮点数转成文字后还能无损读回原值 return QString::number( static_cast(typed_value), 'g', std::numeric_limits::max_digits10); @@ -73,6 +78,7 @@ QString numericValueText(const RegisterNumericValue &value) } else { + // 整数先扩成 qlonglong,统一调用 QString 的有符号整数重载 return QString::number(static_cast(typed_value)); } }, @@ -83,6 +89,7 @@ QString numericValueText(const RegisterNumericValue &value) double numericValueAsDouble(const RegisterNumericValue &value) { // std::visit 统一处理整数、浮点数等不同寄存器数值类型 + // 返回 double 只用于对话框初值,真正写入时仍按控件数据类型重新编码校验 return std::visit( [](const auto &typed_value) { @@ -106,7 +113,9 @@ std::optional requestFloatingPointInput( ? std::numeric_limits::max_digits10 : std::numeric_limits::max_digits10; + // 栈上模态对话框在函数返回时自动销毁,parent 只负责窗口层级和居中 QInputDialog dialog(parent); + // 使用文本输入模式才能安装支持科学计数法的 QDoubleValidator dialog.setInputMode(QInputDialog::TextInput); dialog.setWindowTitle(QObject::tr("输入 %1").arg( QString::fromLatin1(registerDataTypeDescriptor(control.dataType).displayName))); @@ -116,15 +125,20 @@ std::optional requestFloatingPointInput( has_current_value ? numericValueAsDouble(current_value) : 0.0, 'g', digits)); // QInputDialog 没有直接暴露验证器,通过内部输入框安装范围验证 + // findChild 从对话框的 QObject 子树里取得内部输入框 QLineEdit *editor = dialog.findChild(); if (editor != nullptr) { + // validator 以输入框为父对象,输入框销毁时会自动释放 auto *validator = new QDoubleValidator(-maximum, maximum, digits, editor); + // ScientificNotation 同时接受普通小数和 1.2e3 形式 validator->setNotation(QDoubleValidator::ScientificNotation); + // C locale 固定使用点号作为小数点,避免不同电脑输入格式不一致 validator->setLocale(QLocale::c()); editor->setValidator(validator); editor->selectAll(); } + // exec 启动局部模态事件循环,只有点击确认才返回 Accepted if (dialog.exec() != QDialog::Accepted) { return std::nullopt; @@ -132,6 +146,7 @@ std::optional requestFloatingPointInput( bool converted = false; // 使用固定 C locale,确保输入格式不受系统区域设置影响 const double value = QLocale::c().toDouble(dialog.textValue(), &converted); + // 再调用统一编码器检查该 double 能否由目标寄存器类型准确接受 return converted && encodeRegisterNumericValue(control.dataType, value).has_value() ? std::optional{value} : std::nullopt; } @@ -153,6 +168,7 @@ public: : control_(control), page_width_(page_width), page_height_(page_height), + // std::move 把回调所有权转入图元,避免复制较重的 std::function moved_(std::move(moved)), button_event_(std::move(button_event)), numeric_input_activated_(std::move(numeric_input_activated)), @@ -160,8 +176,10 @@ public: alarm_acknowledge_(std::move(alarm_acknowledge)) { // 领域坐标直接作为图元在场景中的初始位置 + // setPos 设置的是图元原点在场景中的位置,不会改动 boundingRect setPos(control_.bounds.x, control_.bounds.y); // 所有控件均可选中,便于主窗口显示对应属性 + // QGraphicsItem 标志决定场景默认事件处理能否选择和拖动图元 setFlag(ItemIsSelectable, true); // 开启位置变化通知,让 itemChange 可以限制拖动坐标 setFlag(ItemSendsGeometryChanges, true); @@ -172,10 +190,12 @@ public: // 返回图元自身坐标系中的矩形范围,用于绘制和命中测试 QRectF boundingRect() const override { + // Qt 用 boundingRect 做裁剪、碰撞和重绘判断,必须包住 paint 的全部像素 if (!control_.binding.has_value()) { return controlRect(); } + // united 返回同时覆盖控件本体和上方地址标签的最小矩形 return controlRect().united(addressRect()); } @@ -185,13 +205,17 @@ public: const QStyleOptionGraphicsItem *option, QWidget *) override { + // painter 此时使用图元本地坐标,图元的 scenePos 由场景变换自动叠加 // 留出一个像素边距,避免描边被图元边界裁剪 const QRectF rect = controlRect().adjusted(1, 1, -1, -1); + // 抗锯齿让圆角、椭圆和斜线边缘更平滑 painter->setRenderHint(QPainter::Antialiasing, true); + // pen 负责轮廓和文字,brush 负责封闭图形内部填充 painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); if (control_.binding.has_value()) { + // 先保存原字体,画完小号地址后恢复,避免影响控件正文 const QFont original_font = painter->font(); QFont address_font = original_font; address_font.setPixelSize(11); @@ -204,10 +228,12 @@ public: applyConfiguredFont(painter); + // 每种领域控件复用同一个图元类,只在绘制阶段按类型选择外观 switch (control_.type) { case HmiControlType::Button: { + // 编辑态不按运行写权限置灰,运行态则同时检查通信写权限和启用条件 const bool disabled = !editing_enabled_ && runtime_active_ && (!runtime_write_enabled_ || !button_condition_enabled_); const QColor fill = disabled @@ -247,6 +273,7 @@ public: case HmiControlType::Indicator: { // 指示灯颜色由最近一次读取到的 M 位值决定 + // 取宽高较小值保证灯始终是圆形,并给下方说明文字预留高度 const qreal diameter = std::min(rect.width(), rect.height() - 18.0); const QRectF lamp( rect.center().x() - diameter / 2.0, @@ -288,6 +315,7 @@ public: } case HmiControlType::StatusText: { + // TextWordWrap 允许状态文字在控件固定宽度内自动换行 painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); painter->drawText( rect.adjusted(4, 0, -4, 0), @@ -297,6 +325,7 @@ public: } case HmiControlType::PageJump: { + // 页面跳转只在运行态显示悬停反馈,编辑态点击仍用于选中和拖动 const QColor fill = runtime_active_ && page_hovered_ ? QColor(QStringLiteral("#d8eafa")) : QColor(QStringLiteral("#e8f1fa")); @@ -309,6 +338,7 @@ public: } case HmiControlType::AlarmList: { + // 报警列表完全由 QPainter 绘制,没有为每一行创建额外 QWidget painter->setPen(QPen(QColor(QStringLiteral("#9b3a3a")), 1)); painter->setBrush(QColor(QStringLiteral("#ffffff"))); painter->drawRect(rect); @@ -317,6 +347,7 @@ public: painter->fillRect(header, QColor(QStringLiteral("#a63f3f"))); painter->setPen(Qt::white); QRectF title_rect = header.adjusted(7, 0, -7, 0); + // 页数由控件当前高度和报警记录数量实时计算 const std::size_t page_count = alarmPageCount(); if (runtime_active_ && page_count > 1U) { @@ -325,10 +356,13 @@ public: const QRectF indicator_rect = alarmPageIndicatorRect(header); title_rect.setRight(previous_rect.left() - kAlarmColumnSpacing); + // QStyleOption 把区域和启用状态交给当前系统主题绘制标准箭头 QStyleOption previous_option; + // QStyle 接受整数像素矩形,因此将 QRectF 对齐到设备像素 previous_option.rect = previous_rect.toAlignedRect(); previous_option.state = alarm_page_ > 0U ? QStyle::State_Enabled : QStyle::State_None; + // QApplication::style 返回整个应用当前使用的 Qt 样式对象 QApplication::style()->drawPrimitive( QStyle::PE_IndicatorArrowLeft, &previous_option, @@ -355,6 +389,7 @@ public: painter->drawText( title_rect, Qt::AlignVCenter | Qt::AlignLeft, + // elidedText 在空间不足时用省略号截断,避免标题覆盖分页按钮 painter->fontMetrics().elidedText( title, Qt::ElideRight, @@ -375,9 +410,11 @@ public: : QObject::tr("运行时显示当前报警")); break; } + // first_record 把当前页内行号换算成完整报警数组下标 const std::size_t first_record = alarmFirstRecordIndex(); for (int row = 0; row < visible_rows; ++row) { + // 这里保存引用而不是复制,绘制一行时不会复制整条报警记录 const AlarmRecord &record = alarm_records_[ first_record + static_cast(row)]; const QRectF row_rect( @@ -445,6 +482,7 @@ public: } } + // option->state 是位标志集合,按位与检查当前图元是否被场景选中 if ((option->state & QStyle::State_Selected) != 0) { // 选中框独立于控件类型,提示当前可编辑对象 @@ -464,6 +502,7 @@ public: void setInteractionState( bool editable, bool runtime_active, bool runtime_write_enabled) { + // 先记住旧状态,用于识别刚刚进入运行态这一条状态边界 const bool runtime_was_active = runtime_active_; if (control_.type == HmiControlType::AlarmList && !runtime_active) { @@ -480,10 +519,12 @@ public: { button_condition_enabled_ = false; } + // 修改 ItemIsMovable 后,QGraphicsScene 的默认鼠标处理会自动允许或禁止拖动 setFlag(ItemIsMovable, editable); setFlag(ItemIsSelectable, editable); if (!editable) { + // 进入运行态时清除编辑选框,避免看起来仍能编辑控件 setSelected(false); } @@ -500,6 +541,7 @@ public: && (control_.type == HmiControlType::PageJump || control_.type == HmiControlType::AlarmList || runtime_write_input_enabled); + // NoButton 会让鼠标事件穿过当前图元,不会进入下面的事件重载 setAcceptedMouseButtons( editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); @@ -510,10 +552,12 @@ public: && control_.type == HmiControlType::PageJump; const bool runtime_alarm_enabled = runtime_active_ && control_.type == HmiControlType::AlarmList; + // 只有显式开启悬停事件后,Qt 才会调用 hoverEnterEvent 和 hoverLeaveEvent setAcceptHoverEvents(runtime_button_enabled || runtime_page_jump_enabled); if (runtime_button_enabled || runtime_page_jump_enabled || runtime_alarm_enabled) { + // setCursor 只改变鼠标外观,不代表业务写入一定成功 setCursor(Qt::PointingHandCursor); } else @@ -526,6 +570,7 @@ public: button_pressed_ = false; } } + // update 只把图元标记为待重绘,真正 paint 在下一轮事件循环执行 update(); } @@ -536,6 +581,7 @@ public: bit_value_ = bit_value; numeric_value_ = numeric_value; has_runtime_value_ = available; + // 缓存改变后请求局部重绘,不需要重建整个 QGraphicsScene update(); } @@ -559,6 +605,7 @@ public: // 更新报警记录并将当前页限制在有效分页范围内 void setAlarmRecords(const std::vector &records) { + // 值复制让图元持有稳定快照,不依赖 AlarmService 内部容器地址 alarm_records_ = records; const std::size_t page_count = alarmPageCount(); alarm_page_ = page_count == 0U @@ -570,25 +617,30 @@ protected: // 拖拽过程中将新位置限制在页面可见边界内 QVariant itemChange(GraphicsItemChange change, const QVariant &value) override { + // ItemPositionChange 发生在位置真正写入前,返回值可以替换即将采用的位置 // 只有开启 ItemIsMovable 拖拽时,才做坐标钳位 if (change == ItemPositionChange && flags().testFlag(ItemIsMovable)) { // 在图元层预先截断拖拽坐标,避免控件视觉上越出页面 + // QVariant 是 Qt 通用值容器,这里按该通知契约取回 QPointF QPointF position = value.toPointF(); const qreal maximum_x = std::max( 0.0, static_cast(page_width_ - control_.bounds.width)); const qreal maximum_y = std::max( 0.0, static_cast(page_height_ - control_.bounds.height)); + // clamp 把拖动坐标压进闭区间,控件右下角不会越过页面边缘 position.setX(std::clamp(position.x(), 0.0, maximum_x)); position.setY(std::clamp(position.y(), 0.0, maximum_y)); return position; } + // 未处理的变化必须交回基类,保留 Qt 默认的选择和可见性处理 return QGraphicsItem::itemChange(change, value); } // 运行态按钮按下时通知外层执行配置的 M 位操作 void mousePressEvent(QGraphicsSceneMouseEvent *event) override { + // event->pos 是图元本地坐标,可直接和 controlRect 内的子区域比较 if (runtime_active_ && control_.type == HmiControlType::AlarmList) { if (event->pos().y() < kAlarmHeaderHeight) @@ -604,6 +656,7 @@ protected: if (alarmPreviousPageRect(header).contains(event->pos()) && alarm_page_ > 0U) { + // prepareGeometryChange 让场景先刷新该图元的几何索引缓存 prepareGeometryChange(); --alarm_page_; update(); @@ -615,6 +668,7 @@ protected: ++alarm_page_; update(); } + // accept 表示本次点击已处理,不再交给场景选择或底层图元 event->accept(); return; } @@ -653,6 +707,7 @@ protected: return; } // 编辑模式:不进if分支,执行基类事件——只做选中、拖拽 + // 编辑态交给基类完成选中、Ctrl 多选和拖动起点记录 QGraphicsItem::mousePressEvent(event); } @@ -668,6 +723,7 @@ protected: event->accept(); return; } + // 非运行输入场景继续使用 QGraphicsItem 默认双击分派 QGraphicsItem::mouseDoubleClickEvent(event); } @@ -695,10 +751,12 @@ protected: event->accept(); return; } + // 基类先结束本次拖动并更新图元最终 pos QGraphicsItem::mouseReleaseEvent(event); // 只有ItemIsMovable打开(编辑态),松开鼠标才提交位置给业务层 if (flags().testFlag(ItemIsMovable) && moved_) { + // pos 返回图元在父项坐标系的位置,本项目顶层图元的父坐标系就是场景 moved_(control_.id, pos()); } } @@ -717,6 +775,7 @@ protected: page_hovered_ = true; update(); } + // 继续交给基类,保留 Qt 对悬停状态的标准处理 QGraphicsItem::hoverEnterEvent(event); } @@ -745,6 +804,7 @@ private: 0, static_cast((rect.height() - kAlarmHeaderHeight) / kAlarmRowHeight)); + // 同时受控件实际高度和项目统一可见行上限约束 return static_cast(std::min( rows_by_height, ProjectLimits::kMaximumVisibleAlarmRows)); } @@ -755,6 +815,7 @@ private: const std::size_t page_size = alarmPageSize(); return page_size == 0U || alarm_records_.empty() ? 0U + // 加 page_size - 1 是整数除法向上取整的常见写法 : (alarm_records_.size() + page_size - 1U) / page_size; } @@ -845,6 +906,7 @@ private: { return fallback; } + // QColor 能解析 #RRGGBB 等 Qt 颜色文本,isValid 负责拒绝非法配置 const QColor color = QColor(QString::fromUtf8( property->second.data(), static_cast(property->second.size()))); return color.isValid() ? color : fallback; @@ -857,12 +919,14 @@ private: { return; } + // 复制当前字体后修改局部副本,最后一次性写回 painter QFont font = painter->font(); const auto font_size = control_.properties.find( HmiAppearanceProperty::kFontSize); if (font_size != control_.properties.cend()) { bool ok = false; + // toInt 通过 ok 返回转换结果,避免非法文字被当成字号零 const int point_size = QString::fromUtf8( font_size->second.data(), static_cast(font_size->second.size())).toInt(&ok); @@ -893,6 +957,7 @@ private: { font.setItalic(font_italic->second == "true"); } + // 后续所有 drawText 都使用这份已经合并配置的字体 painter->setFont(font); } @@ -971,6 +1036,7 @@ private: // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 HmiGraphicsItem *asHmiItem(QGraphicsItem *item) { + // dynamic_cast 失败返回 nullptr,页面边框等其他图元会被安全跳过 return dynamic_cast(item); } @@ -986,13 +1052,18 @@ HmiEditorWidget::HmiEditorWidget( editor_service_(editor_service), runtime_service_(runtime_service), alarm_service_(alarm_service), + // scene 以视图为 QObject 父对象,视图析构时会自动释放场景 scene_(new QGraphicsScene(this)) { setObjectName(QStringLiteral("hmiEditorWidget")); + // setScene 只把视图连接到场景,场景所有权仍由上面的 parent 关系决定 setScene(scene_); + // 视图级抗锯齿会传递给场景中每个图元的 QPainter setRenderHint(QPainter::Antialiasing, true); + // RubberBandDrag 开启鼠标拖框多选,运行态会切换为 NoDrag setDragMode(QGraphicsView::RubberBandDrag); setBackgroundBrush(QColor(QStringLiteral("#dfe5e9"))); + // 场景内任一图元选择变化都会统一转换成业务控件 ID 信号 connect(scene_, &QGraphicsScene::selectionChanged, this, &HmiEditorWidget::handleSelectionChanged); } @@ -1013,6 +1084,7 @@ void HmiEditorWidget::setPageId(const std::string &page_id) void HmiEditorWidget::setEditingEnabled(bool enabled) { editing_enabled_ = enabled; + // NoDrag 只关闭视图的框选拖动,具体图元权限还要在下一行同步 setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag); updateItemInteractions(); } @@ -1024,6 +1096,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) if (!runtime_active_) { runtime_write_enabled_ = false; + // scene->items 返回场景当前全部图元,包含页面边框和 HMI 控件 for (QGraphicsItem *item : scene_->items()) { HmiGraphicsItem *control_item = asHmiItem(item); @@ -1050,19 +1123,25 @@ void HmiEditorWidget::reloadPage() { // 模型发生变化后完全重建图元,避免增量刷新遗漏属性或选择状态 // 画布始终从当前领域页面重建,避免保留已删除控件的图元 + // clear 会从场景移除并 delete 所有图元,因此旧图元不会泄漏 scene_->clear(); const HmiPage *page = editor_service_.findPage(page_id_); if (page == nullptr) { + // 空场景矩形让 fitCurrentPage 直接跳过缩放 scene_->setSceneRect({}); emit controlSelected({}); return; } + // sceneRect 定义页面的逻辑坐标范围,与视口像素大小不是同一个概念 scene_->setSceneRect(0, 0, page->width, page->height); + // addRect 创建的边框图元由 QGraphicsScene 接管所有权 QGraphicsRectItem *page_border = scene_->addRect( scene_->sceneRect(), QPen(QColor(QStringLiteral("#8a98a3")), 1), Qt::white); + // 较小 Z 值把白色页面底板放到所有业务控件后面 page_border->setZValue(-1); + // 页面底板不接收鼠标,否则空白处点击会命中边框图元 page_border->setAcceptedMouseButtons(Qt::NoButton); for (const HmiControl &control : page->controls) @@ -1093,6 +1172,7 @@ void HmiEditorWidget::reloadPage() alarm_service_.acknowledge(definition_id); refreshRuntimeValues(); }); + // addItem 后场景接管图元,后续 scene->clear 会统一释放 scene_->addItem(item); item->setInteractionState( editing_enabled_, runtime_active_, runtime_write_enabled_); @@ -1104,6 +1184,7 @@ void HmiEditorWidget::reloadPage() // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 void HmiEditorWidget::selectControl(const std::string &control_id) { + // 先清空旧选择,保证这个单选入口最多留下一个选中控件 scene_->clearSelection(); for (QGraphicsItem *item : scene_->items()) { @@ -1111,6 +1192,7 @@ void HmiEditorWidget::selectControl(const std::string &control_id) if (control_item != nullptr && control_item->controlId() == control_id) { control_item->setSelected(true); + // ensureVisible 自动滚动视图,使目标图元进入当前视口 ensureVisible(control_item); return; } @@ -1122,11 +1204,13 @@ void HmiEditorWidget::selectControls( const std::vector &control_ids) { scene_->clearSelection(); + // 记录第一个命中图元,遍历完成后只滚动一次视口 HmiGraphicsItem *first_selected = nullptr; for (QGraphicsItem *item : scene_->items()) { HmiGraphicsItem *control_item = asHmiItem(item); if (control_item == nullptr + // std::find 在业务 ID 集合中确认当前场景图元是否属于目标选择 || std::find( control_ids.cbegin(), control_ids.cend(), control_item->controlId()) == control_ids.cend()) @@ -1155,6 +1239,7 @@ std::string HmiEditorWidget::selectedControlId() const // 按从上到下、从左到右的顺序返回选中控件标识 std::vector HmiEditorWidget::selectedControlIds() const { + // selectedItems 的返回顺序不是视觉顺序,因此先保存位置再自行排序 std::vector> positioned_ids; for (QGraphicsItem *item : scene_->selectedItems()) { @@ -1162,6 +1247,7 @@ std::vector HmiEditorWidget::selectedControlIds() const if (control_item != nullptr) { positioned_ids.emplace_back( + // scenePos 是经过所有父图元变换后的最终场景坐标 control_item->scenePos(), control_item->controlId()); } } @@ -1169,6 +1255,7 @@ std::vector HmiEditorWidget::selectedControlIds() const positioned_ids.begin(), positioned_ids.end(), [](const auto &left, const auto &right) { + // qFuzzyCompare 避免浮点坐标极小误差把同一视觉行错误拆开 if (!qFuzzyCompare(left.first.y(), right.first.y())) { return left.first.y() < right.first.y(); @@ -1176,6 +1263,7 @@ std::vector HmiEditorWidget::selectedControlIds() const return left.first.x() < right.first.x(); }); std::vector ids; + // reserve 只预留容量,随后 push_back 时不会频繁重新分配内存 ids.reserve(positioned_ids.size()); for (const auto &positioned_id : positioned_ids) { @@ -1192,6 +1280,7 @@ void HmiEditorWidget::refreshRuntimeValues() { return; } + // 每轮从服务层重新读取并更新现有图元缓存,不重建页面和选择状态 for (QGraphicsItem *item : scene_->items()) { HmiGraphicsItem *control_item = asHmiItem(item); @@ -1199,6 +1288,7 @@ void HmiEditorWidget::refreshRuntimeValues() { continue; } + // 图元只存创建时快照,运行读取前仍按 ID 获取模型中的最新控件 const HmiControl *control = editor_service_.findControl( page_id_, control_item->controlId()); if (control == nullptr) @@ -1207,11 +1297,13 @@ void HmiEditorWidget::refreshRuntimeValues() } if (control->type == HmiControlType::AlarmList) { + // records 返回当前报警快照,图元内部再复制一份用于稳定绘制 control_item->setAlarmRecords(alarm_service_.records()); continue; } if (control->type == HmiControlType::Button) { + // 每轮刷新都重新计算按钮条件,相关 M/D 值变化后交互状态立即跟随 const HmiButtonEnabledResult enabled = runtime_service_.evaluateButtonEnabled(*control); control_item->setButtonConditionEnabled( @@ -1234,6 +1326,7 @@ void HmiEditorWidget::refreshRuntimeValues() // 视图尺寸变化后保持完整页面可见 void HmiEditorWidget::resizeEvent(QResizeEvent *event) { + // 先让基类更新视口和滚动条尺寸,再根据新尺寸重新计算缩放 QGraphicsView::resizeEvent(event); fitCurrentPage(); } @@ -1246,6 +1339,7 @@ void HmiEditorWidget::fitCurrentPage() return; } // KeepAspectRatio 防止页面和控件被非等比拉伸 + // fitInView 会重设视图变换矩阵,使给定场景矩形完整落入 viewport fitInView( scene_->sceneRect().adjusted(-24, -24, 24, 24), Qt::KeepAspectRatio); @@ -1269,6 +1363,7 @@ void HmiEditorWidget::updateItemInteractions() // 将场景选择变化转换为控件标识信号 void HmiEditorWidget::handleSelectionChanged() { + // emit 发出 Qt 信号,属性面板等接收者会根据连接类型立即或排队处理 emit controlSelected(QString::fromStdString(selectedControlId())); } @@ -1284,16 +1379,19 @@ void HmiEditorWidget::handleControlMoved( } // 鼠标坐标取整后再交给服务校验并写回模型 HmiRect bounds = control->bounds; + // lround 按四舍五入转整数,比直接 static_cast 截断更符合拖动观感 bounds.x = static_cast(std::lround(position.x())); bounds.y = static_cast(std::lround(position.y())); const HmiEditorResult result = editor_service_.moveControl( page_id_, control_id, bounds); if (!result.succeeded) { + // 服务拒绝后发出错误并重载页面,把视觉位置恢复成模型中的合法值 emit editorError(QString::fromStdString(result.message)); reloadPage(); return; } + // 只在服务提交成功后通知外部刷新工程树、属性和修改状态 emit controlChanged(QString::fromStdString(control_id)); } @@ -1315,12 +1413,14 @@ void HmiEditorWidget::handleButtonEvent( { return; } + // Pressed 和 Released 都进入服务,点动按钮依赖这两个事件成对出现 const HmiRuntimeWriteResult result = runtime_service_.operateButton(*control, event); if (!result.succeeded) { if (result.error == HmiRuntimeError::ConditionNotMet || result.error == HmiRuntimeError::ConditionUnavailable) { + // 条件刚失效时静默刷新按钮置灰状态,不把正常拒绝当通信错误 refreshRuntimeValues(); return; } @@ -1342,8 +1442,10 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) { return; } + // 先读当前值作为输入框初值,读取失败时仍允许从零开始输入 const HmiRuntimeReadResult current = runtime_service_.readControl(*control); std::optional value; + // 描述符统一告诉 UI 该类型使用浮点输入还是整数输入 if (registerDataTypeDescriptor(control->dataType).floatingPoint) { value = requestFloatingPointInput( @@ -1358,6 +1460,7 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) const int maximum = control->dataType == RegisterDataType::Int16 ? std::numeric_limits::max() : std::numeric_limits::max(); + // getInt 内置整数范围和步长校验,取消时 accepted 为 false const int input = QInputDialog::getInt( this, tr("输入 %1").arg(QString::fromLatin1( @@ -1379,6 +1482,7 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) { return; } + // 服务把 double 按 Int16、Int32、Float32 或 Float64 编码为连续 D 字 const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( *control, *value); if (!result.succeeded) diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 0b69c86..a55da76 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -28,6 +28,7 @@ namespace { +// 下面尺寸全部使用 QGraphicsScene 的逻辑坐标,不是显示器物理像素 constexpr qreal kLabelWidth = 60.0; constexpr qreal kCellWidth = 96.0; constexpr qreal kOutputWidth = 224.0; @@ -139,6 +140,7 @@ QString logicCommandText(const LogicNodeConfig &config) // 从输入文本提取大写指令助记符作为补全前缀 QString commandCompletionPrefix(const QString &text) { + // section 按正则空白切分并取第一段,因此操作数不会参与助记符补全 return text.section(QRegularExpression(QStringLiteral("\\s+")), 0, 0) .toUpper(); } @@ -146,8 +148,11 @@ QString commandCompletionPrefix(const QString &text) // 创建普通、导通或故障状态的梯形图画笔 QPen ladderPen(bool active, bool faulted = false) { + // 嵌套三元表达式规定故障色优先,其次才是导通色和普通色 QPen pen(faulted ? kFaultColor : active ? kActiveColor : kLadderColor); + // setWidthF 使用浮点宽度,缩放后比整数线宽更平滑 pen.setWidthF(active || faulted ? 2.5 : 1.8); + // MiterJoin 和 SquareCap 让梯形图直角连接和线端保持硬朗 pen.setJoinStyle(Qt::MiterJoin); pen.setCapStyle(Qt::SquareCap); return pen; @@ -157,6 +162,7 @@ QPen ladderPen(bool active, bool faulted = false) bool containsId( const std::vector &ids, const std::string &candidate) { + // std::find 返回 end 表示遍历完整个 vector 仍未找到候选值 return std::find(ids.cbegin(), ids.cend(), candidate) != ids.cend(); } @@ -181,12 +187,14 @@ public: explicit GridLayerItem(std::vector rows) : rows_(std::move(rows)) { + // Z 值越小越靠后,背景网格位于业务符号和选择覆盖层下面 setZValue(0.0); } // 返回所有梯形图视觉行共同占用的场景范围 QRectF boundingRect() const override { + // Qt 依赖该矩形决定何时调用 paint,空布局直接返回空矩形 if (rows_.empty()) { return {}; @@ -201,6 +209,7 @@ public: // 绘制白色行背景、外框和条件列分隔线 void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { + // 网格只画水平和垂直直线,关闭抗锯齿可避免一像素线发虚 painter->setRenderHint(QPainter::Antialiasing, false); QPen grid_pen(kGridColor); grid_pen.setWidthF(1.0); @@ -255,9 +264,12 @@ public: : cell_(cell), input_active_(input_active), output_active_(output_active), faulted_(faulted) { + // 每个单元格图元的本地原点都是左上角,setPos 把它放到场景对应格 setPos(top_left); + // 单元格高于背景和母线,但低于透明命中层与选择覆盖层 setZValue(10.0); // QGraphicsItem::data 只保存稳定定位信息,实际修改仍由服务层完成 + // data(role) 是 QGraphicsItem 自带的轻量键值存储,这里约定 0 为类型 setData(0, QStringLiteral("cell")); setData(1, QString::fromStdString(rung_id)); setData(2, column); @@ -272,6 +284,7 @@ public: // 返回单个条件格的固定绘制和命中范围 QRectF boundingRect() const override { + // 返回本地坐标矩形,场景会自动叠加构造函数中的 setPos return {0.0, 0.0, kCellWidth, kRowHeight}; } @@ -283,6 +296,7 @@ public: return; } painter->setRenderHint(QPainter::Antialiasing, true); + // 小 lambda 统一切换输入段和输出段的轨迹颜色 const auto use_pen = [this, painter](bool active) { painter->setPen(ladderPen(active, faulted_)); @@ -300,14 +314,17 @@ public: return; } + // 前面已经排除 node 为空的情况,此处解引用 optional 是安全的 const LogicNode &node = *cell_.node; QFont font = painter->font(); font.setPointSizeF(8.5); painter->setFont(font); // 根据 variant 中的具体节点类型绘制对应符号 + // variant 保存不同节点配置,visit 会进入与实际类型匹配的分支 std::visit( [&](const auto &config) { + // decay_t 去掉 lambda 参数的 const 和引用,便于做精确类型比较 using Config = std::decay_t; if constexpr (std::is_same_v || std::is_same_v) @@ -400,6 +417,7 @@ public: input_active_(input_active), symbol_active_(symbol_active), faulted_(faulted) { + // 输出槽图元保存模型值快照,模型变化后由 rebuildScene 创建新图元 setPos(top_left); setZValue(10.0); // 输出槽元数据用于双击配置、选择和删除时还原业务位置 @@ -420,6 +438,7 @@ public: // 根据输出类型绘制线圈、MOVE、ADD 或 SUB void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { + // 空输出槽仍保留命中范围,但不绘制任何符号 if (!output_.has_value()) { return; @@ -430,6 +449,7 @@ public: painter->setPen(ladderPen(active, faulted_)); }; const qreal center_y = kRowHeight / 2.0 + 6.0; + // optional 已确认有值,引用可以避免复制整个 LogicNode const LogicNode &node = *output_; QFont font = painter->font(); font.setPointSizeF(8.5); @@ -445,8 +465,10 @@ public: painter->drawLine(QPointF(0.0, center_y), QPointF(center_x - 22.0, center_y)); painter->drawLine(QPointF(center_x + 22.0, center_y), QPointF(kOutputWidth, center_y)); use_pen(symbol_active_); + // QPainterPath 用三次贝塞尔曲线绘制线圈左右弧线 QPainterPath left; left.moveTo(center_x - 3.0, center_y - 18.0); + // cubicTo 的前两个点是控制点,最后一个点是曲线终点 left.cubicTo( center_x - 25.0, center_y - 14.0, center_x - 25.0, center_y + 14.0, @@ -561,6 +583,7 @@ public: bool active) : height_(bottom - top), active_(active) { + // 竖线本地 x 固定为零,场景位置负责放到目标列边界 setPos(x, top); setZValue(20.0); // 竖线元数据保留上下行和列边界,便于逐段选择和删除 @@ -575,15 +598,18 @@ public: // 返回比可见线条更宽的基础命中矩形 QRectF boundingRect() const override { + // 外接矩形比可见线宽,给重绘和鼠标命中留出容差 return {-7.0, 0.0, 14.0, height_}; } // 将细竖线扩展为便于鼠标命中的路径 QPainterPath shape() const override { + // shape 决定精确碰撞区域,可以和 boundingRect 不同 QPainterPath path; path.moveTo(0.0, 0.0); path.lineTo(0.0, height_); + // Stroker 把一条没有面积的中心线扩成可点击的封闭轮廓 QPainterPathStroker stroker; // 可见竖线保持细线,命中形状单独扩宽到 12 像素 stroker.setWidth(12.0); @@ -610,10 +636,12 @@ public: explicit SelectionOverlayItem(std::vector rectangles) : rectangles_(std::move(rectangles)) { + // united 逐个合并外接矩形,让 Qt 只在相关区域刷新覆盖层 for (const QRectF &rectangle : rectangles_) { bounds_ = bounds_.isNull() ? rectangle : bounds_.united(rectangle); } + // 向外扩两单位,防止虚线边框被 boundingRect 裁掉 bounds_.adjust(-2.0, -2.0, 2.0, 2.0); setZValue(100.0); } @@ -631,6 +659,7 @@ public: QPen pen(kSelectionBorder, 1.4, Qt::DashLine); pen.setCosmetic(true); painter->setPen(pen); + // QColor 第四个参数是 Alpha,90 表示半透明填充 painter->setBrush(QColor( kSelectionFill.red(), kSelectionFill.green(), @@ -656,17 +685,23 @@ LogicEditorWidget::LogicEditorWidget( : QGraphicsView(parent), editor_service_(editor_service), command_service_(editor_service), + // QGraphicsScene 以视图为父对象,视图析构时自动释放场景及其图元 scene_(new QGraphicsScene(this)) { + // setScene 建立视图与场景的显示关系,但不改变上面的 QObject 所有权 setScene(scene_); setRenderHint(QPainter::Antialiasing, true); setBackgroundBrush(kCanvasBackground); + // 选择框由本类自己管理,因此关闭 QGraphicsView 内置拖动模式 setDragMode(QGraphicsView::NoDrag); + // 左上对齐让场景原点稳定贴近视口左上角,不随窗口尺寸居中漂移 setAlignment(Qt::AlignLeft | Qt::AlignTop); + // QRubberBand 的 parent 是 viewport,所以它使用视口像素坐标而非场景坐标 selection_band_ = new QRubberBand(QRubberBand::Rectangle, viewport()); selection_band_->hide(); // 输入框以 viewport 为父对象,缩放和滚动画布时仍可按屏幕坐标覆盖目标格 + // 输入框也是 viewport 子控件,因此它浮在画布上方而不是成为场景图元 command_editor_ = new QLineEdit(viewport()); command_editor_->setObjectName(QStringLiteral("logicCommandInput")); command_editor_->setPlaceholderText( @@ -675,7 +710,9 @@ LogicEditorWidget::LogicEditorWidget( command_editor_->setVisible(false); command_editor_->setToolTip( tr("触点使用 M 地址,比较和数据指令使用 D 地址")); + // completer 由输入框持有,输入框销毁时自动释放 command_completer_ = new QCompleter(command_editor_); + // 三列标准模型由 completer 持有,分别显示指令、操作数和说明 auto *command_model = new QStandardItemModel(0, 3, command_completer_); command_model->setHeaderData(0, Qt::Horizontal, tr("指令")); command_model->setHeaderData(1, Qt::Horizontal, tr("操作数")); @@ -683,6 +720,7 @@ LogicEditorWidget::LogicEditorWidget( for (const LogicCommandSuggestion &suggestion : LogicCommandService::suggestions()) { + // appendRow 后模型接管这些 QStandardItem 指针的生命周期 QList row{ new QStandardItem(QString::fromStdString(suggestion.mnemonic)), new QStandardItem(QString::fromStdString(suggestion.operand_hint)), @@ -695,10 +733,12 @@ LogicEditorWidget::LogicEditorWidget( command_model->appendRow(row); } command_completer_->setModel(command_model); + // 只用第零列助记符做匹配,另外两列仅供用户阅读 command_completer_->setCompletionColumn(0); command_completer_->setCaseSensitivity(Qt::CaseInsensitive); command_completer_->setCompletionMode(QCompleter::PopupCompletion); // 自定义三列表格弹窗,同时展示助记符、操作数格式和说明 + // setPopup 会让 QCompleter 使用并管理这个自定义弹窗视图 auto *command_popup = new QTreeView; command_popup->setRootIsDecorated(false); command_popup->setItemsExpandable(false); @@ -707,10 +747,12 @@ LogicEditorWidget::LogicEditorWidget( command_popup->setAlternatingRowColors(true); command_popup->setMinimumSize(620, 300); command_completer_->setPopup(command_popup); + // 最后一列自动占满剩余宽度,前两列保持固定便于扫读 command_popup->header()->setStretchLastSection(true); command_popup->setColumnWidth(0, 96); command_popup->setColumnWidth(1, 190); command_editor_->setCompleter(command_completer_); + // textEdited 只响应用户键入,程序 setText 不会意外弹出补全列表 connect( command_editor_, &QLineEdit::textEdited, this, @@ -718,17 +760,20 @@ LogicEditorWidget::LogicEditorWidget( { command_completer_->setCompletionPrefix( commandCompletionPrefix(text)); + // complete 的矩形使用输入框本地坐标,弹窗会显示在该矩形附近 command_completer_->complete(command_editor_->rect()); }); connect( command_editor_, &QLineEdit::returnPressed, this, &LogicEditorWidget::commitCommandInput); + // QLineEdit 没有取消信号,通过事件过滤器接管 Escape 和失焦取消 command_editor_->installEventFilter(this); } // 切换当前控制逻辑并重建场景和选择状态 void LogicEditorWidget::setLogicId(const std::string &logic_id) { + // 同一个逻辑也会调用本函数用于重新校正选择,因此不能直接提前返回 const bool logic_changed = logic_id_ != logic_id; clearGesture(); logic_id_ = logic_id; @@ -774,6 +819,7 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) selected_output_ = false; selected_boundary_ = false; } + // remove_if 先把失效元素移到尾部,erase 再真正缩短 vector selected_cells_.erase( std::remove_if( selected_cells_.begin(), @@ -815,6 +861,7 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) return editor_service_.findRung(logic_id_, rung_id) == nullptr; }), selected_row_ids_.end()); + // 模型变化后根据仍有效的格和输出重新生成节点 ID 选择 synchronizeSelectedNodes(); selected_vertical_connection_id_ = selected_vertical_connection_ids_.empty() @@ -828,12 +875,14 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) selected_cell_ = false; } } + // 不论是否切换 ID,最终都从当前模型重建一次画面 rebuildScene(); } // 切换画布编辑权限并关闭不再允许的临时交互 void LogicEditorWidget::setEditingEnabled(bool enabled) { + // 权限变化前先丢弃未提交手势,避免运行态继续提交旧拖动 clearGesture(); editing_enabled_ = enabled; if (!enabled) @@ -842,6 +891,7 @@ void LogicEditorWidget::setEditingEnabled(bool enabled) mouse_wire_mode_ = MouseWireMode::Select; selection_pressed_ = false; selection_dragging_ = false; + // 隐藏只是取消视觉框,选择集合由后续场景重建决定 selection_band_->hide(); } rebuildScene(); @@ -855,6 +905,7 @@ void LogicEditorWidget::setMouseWireMode(MouseWireMode mode) selection_pressed_ = false; selection_dragging_ = false; selection_band_->hide(); + // 光标属于 viewport,因为实际接收鼠标事件的是视口子控件 viewport()->setCursor(mouse_wire_mode_ == MouseWireMode::Select ? Qt::ArrowCursor : Qt::CrossCursor); } @@ -865,11 +916,13 @@ LogicEditorWidget::MouseWireMode LogicEditorWidget::mouseWireMode() const return mouse_wire_mode_; } -// 保存运行轨迹和故障节点后重建只读场景 +// 保存完整工程轨迹,按当前 logic_id_ 取出对应逻辑后重建只读亮线场景 void LogicEditorWidget::setRuntimeTrace( const LogicTraceSnapshot &trace, const std::string &fault_node_id) { + // forLogic 只做 UI 投影筛选,真机轨迹已经由 OnlineLogicMonitorService 计算完成 + // 保存值快照而非外部引用,异步产生下一帧轨迹时当前绘制仍然稳定 trace_ = trace.forLogic(logic_id_); fault_node_id_ = fault_node_id; runtime_trace_enabled_ = true; @@ -906,6 +959,7 @@ void LogicEditorWidget::clearSelection() selected_boundary_ = false; selected_vertical_connection_id_.clear(); rebuildScene(); + // 空 QString 告诉属性面板清除当前节点详情 emit nodeSelected(QString{}); } @@ -925,12 +979,14 @@ void LogicEditorWidget::selectNode(const std::string &node_id) if (!node_id.empty()) { selected_node_ids_.push_back(node_id); + // 先由服务把稳定节点 ID 反查到所属行,再定位条件列或输出槽 const std::string rung_id = editor_service_.rungIdForNode( logic_id_, node_id); const LadderRung *rung = editor_service_.findRung(logic_id_, rung_id); if (rung != nullptr) { selected_rung_id_ = rung_id; + // 条件节点需要遍历固定十格,输出节点则在循环后单独判断 for (std::size_t index = 0U; index < rung->cells.size(); ++index) { if (rung->cells[index].node.has_value() @@ -951,6 +1007,7 @@ void LogicEditorWidget::selectNode(const std::string &node_id) } } rebuildScene(); + // 场景和内部选择更新完成后再通知外部,接收者看到的是一致状态 emit nodeSelected(QString::fromStdString(node_id)); } @@ -973,6 +1030,7 @@ void LogicEditorWidget::focusSyntaxLocation( selected_rung_id_ = rung_id; selected_boundary_ = false; + // 语法位置使用一开始的列号,超过条件总列数即表示输出槽 const bool output = column >= ProjectLimits::kMaximumLadderColumns; selected_output_ = output; selected_cell_ = !output; @@ -1010,6 +1068,7 @@ void LogicEditorWidget::focusSyntaxLocation( const qreal x = output ? kConditionRight : kLeftBus + static_cast(selected_column_) * kCellWidth; + // ensureVisible 自动调整滚动条,并在目标四周保留 24 单位空白 ensureVisible( QRectF( x, @@ -1019,6 +1078,7 @@ void LogicEditorWidget::focusSyntaxLocation( 24, 24); } + // 让后续 Delete、Escape 等键盘事件继续进入画布 setFocus(Qt::OtherFocusReason); } @@ -1054,6 +1114,7 @@ bool LogicEditorWidget::hasCopyableSelection() const // 将当前显式选择交给服务层生成剪贴板片段 LogicClipboardCopyResult LogicEditorWidget::copySelection() const { + // UI 只组装稳定 ID 和格坐标,具体片段复制规则由服务层统一执行 LogicSelectionCopyRequest selection; selection.cells = selected_cells_; selection.outputRungIds = selected_output_rung_ids_; @@ -1065,6 +1126,7 @@ LogicClipboardCopyResult LogicEditorWidget::copySelection() const // 根据当前光标和整行选择生成粘贴目标 LogicPasteTarget LogicEditorWidget::pasteTarget() const { + // 没有当前行时回退到第一行,空逻辑的合法性仍由服务层处理 LogicPasteTarget target; target.rungId = selected_rung_id_.empty() ? editor_service_.firstRungId(logic_id_) @@ -1109,10 +1171,12 @@ int LogicEditorWidget::rowAt(const std::string &rung_id) const // 从模型计算每条视觉行的顶部、中心和网络首行状态 void LogicEditorWidget::rebuildRowLayout(const ControlLogic &logic) { + // 布局是模型到场景坐标的缓存,每次重建都从顶部重新累计 row_layouts_.clear(); qreal next_top = kTop; for (std::size_t row = 0U; row < logic.rungs.size(); ++row) { + // 每个网络首行上方额外留出注释带,其余支路行紧邻排列 const bool network_head = logic.networkHeadIndex(row) == row; if (network_head) { @@ -1125,6 +1189,7 @@ void LogicEditorWidget::rebuildRowLayout(const ControlLogic &logic) layout.centerY = next_top + kRowHeight / 2.0 + 6.0; layout.bottom = next_top + kRowHeight; layout.networkHead = network_head; + // push_back 后 row_layouts_ 的顺序始终与模型 rungs 顺序一致 row_layouts_.push_back(std::move(layout)); next_top += kRowHeight; } @@ -1134,6 +1199,7 @@ void LogicEditorWidget::rebuildRowLayout(const ControlLogic &logic) const LogicEditorWidget::RowLayout *LogicEditorWidget::layoutForRung( const std::string &rung_id) const { + // 用稳定 ID 查找而不保存模型下标,插入删除行后仍能正确定位 const auto found = std::find_if( row_layouts_.cbegin(), row_layouts_.cend(), @@ -1141,6 +1207,7 @@ const LogicEditorWidget::RowLayout *LogicEditorWidget::layoutForRung( { return layout.rungId == rung_id; }); + // &*found 取得 vector 内元素地址,调用者只在下次重建布局前临时使用 return found == row_layouts_.cend() ? nullptr : &*found; } @@ -1149,11 +1216,14 @@ LogicEditorWidget::Hit LogicEditorWidget::hitAt( const QPointF &scene_position) const { // scene.items 按 Z 值从上到下返回,先识别竖线和透明命中层 + // scene_position 已是场景坐标,items 会返回该点下所有相互重叠图元 const QList items = scene_->items(scene_position); + // lambda 按 data(0) 中保存的类型标签,把 Qt 图元还原成业务命中结构 const auto hit_for_type = [&items](const QString &wanted) -> Hit { for (QGraphicsItem *item : items) { + // 没设置 data(0) 的背景、母线和文字会自然跳过 if (item->data(0).toString() != wanted) { continue; @@ -1197,6 +1267,7 @@ LogicEditorWidget::Hit LogicEditorWidget::hitAt( return {}; }; + // 命中优先级由这里明确规定,避免重叠图元的 Z 顺序改变业务语义 Hit hit = hit_for_type(QStringLiteral("rowHeader")); if (!hit.rungId.empty()) { @@ -1228,6 +1299,7 @@ LogicEditorWidget::Hit LogicEditorWidget::hitAt( // 根据当前模型、选择和运行轨迹重建完整场景 void LogicEditorWidget::rebuildScene() { + // clear 会删除场景拥有的旧图元,所有图元都将在下面按最新模型重建 scene_->clear(); row_layouts_.clear(); const ControlLogic *logic = editor_service_.findLogic(logic_id_); @@ -1237,6 +1309,7 @@ void LogicEditorWidget::rebuildScene() return; } rebuildRowLayout(*logic); + // 场景高度跟随最后一行底部,并留出滚动时可见的下边距 const qreal height = row_layouts_.empty() ? 120.0 : row_layouts_.back().bottom + 24.0; scene_->setSceneRect( @@ -1247,6 +1320,7 @@ void LogicEditorWidget::rebuildScene() } std::vector grid_rows; + // reserve 只预留内存,不会创建 GridRow 元素 grid_rows.reserve(logic->rungs.size()); for (std::size_t row = 0U; row < logic->rungs.size(); ++row) { @@ -1255,10 +1329,12 @@ void LogicEditorWidget::rebuildScene() grid_row.top = layout.gridTop; grid_rows.push_back(std::move(grid_row)); } + // addItem 后 QGraphicsScene 接管 new 出来的背景图元 scene_->addItem(new GridLayerItem(std::move(grid_rows))); QPen bus_pen(kLadderColor); bus_pen.setWidthF(2.2); + // cosmetic 让母线在缩放时仍保持固定屏幕宽度 bus_pen.setCosmetic(true); QGraphicsLineItem *left_bus = scene_->addLine( kLeftBus, @@ -1279,13 +1355,16 @@ void LogicEditorWidget::rebuildScene() { const LadderRung &rung = logic->rungs[row]; const RowLayout &layout = row_layouts_[row]; + // 透明行号命中层覆盖左侧区域,视觉文字另用 SimpleTextItem 绘制 QGraphicsRectItem *row_header = scene_->addRect( QRectF(0.0, layout.gridTop, kLeftBus - 4.0, kRowHeight), QPen(Qt::NoPen), QBrush(Qt::transparent)); + // 行号命中只需类型和稳定行 ID,不直接保存 LadderRung 指针 row_header->setData(0, QStringLiteral("rowHeader")); row_header->setData(1, QString::fromStdString(rung.id)); row_header->setZValue(29.0); + // addSimpleText 创建并由场景管理一个轻量只读文字图元 QGraphicsSimpleTextItem *label = scene_->addSimpleText( QStringLiteral("%1") .arg(static_cast(row), 3, 10, QLatin1Char('0'))); @@ -1305,12 +1384,14 @@ void LogicEditorWidget::rebuildScene() layout.gridTop - kCommentBandHeight + 4.0); comment->setZValue(6.0); } + // 每一行固定创建十个条件格,包括 Gap,保证任意空格都可命中编辑 for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { const LadderCell &cell = rung.cells[static_cast(column)]; const qreal x = kLeftBus + static_cast(column) * kCellWidth; + // map::find 不会插入缺失键,轨迹没有该格时按未导通绘制 const auto input_power = trace_.cellInputPowerValues.find(cell.id); const bool input_active = runtime_trace_enabled_ && input_power != trace_.cellInputPowerValues.end() @@ -1321,6 +1402,7 @@ void LogicEditorWidget::rebuildScene() && output_power->second; const bool faulted = cell.node.has_value() && cell.node->id == fault_node_id_; + // CellContentItem 保存绘制快照和稳定定位元数据,不修改模型 scene_->addItem(new CellContentItem( cell, rung.id, @@ -1330,11 +1412,13 @@ void LogicEditorWidget::rebuildScene() output_active, faulted)); } + // 十格条件区共有十一个列边界,包含左右两端 for (int boundary = 0; boundary <= ProjectLimits::kMaximumConditionColumns; ++boundary) { const qreal x = kLeftBus + static_cast(boundary) * kCellWidth; + // 透明粗线只负责命中,用户看到的竖线由独立图元绘制 QPen boundary_pen(Qt::transparent); boundary_pen.setWidthF(12.0); QGraphicsLineItem *hit_line = scene_->addLine( @@ -1346,11 +1430,13 @@ void LogicEditorWidget::rebuildScene() hit_line->setData(0, QStringLiteral("boundary")); hit_line->setData(1, QString::fromStdString(rung.id)); hit_line->setData(2, boundary); + // 边界命中层高于单元格,靠近格边时优先识别竖线操作 hit_line->setZValue(30.0); } const auto rung_power = trace_.rungValues.find(rung.id); const bool input_active = runtime_trace_enabled_ && rung_power != trace_.rungValues.end() && rung_power->second; + // 输入电源和输出符号状态分开保存,线圈可显示不同阶段轨迹 bool symbol_active = false; if (rung.output.has_value()) { @@ -1368,6 +1454,7 @@ void LogicEditorWidget::rebuildScene() symbol_active, faulted)); } + // 模型竖线用上下稳定行 ID 连接,布局缓存负责换算成场景坐标 for (const VerticalConnection &connection : logic->verticalConnections) { const RowLayout *upper = layoutForRung(connection.upperRungId); @@ -1378,6 +1465,7 @@ void LogicEditorWidget::rebuildScene() } const qreal x = kLeftBus + static_cast(connection.columnBoundary) * kCellWidth; + // 只有开启运行轨迹且映射值为真时才使用导通颜色 const auto power = trace_.verticalConnectionValues.find(connection.id); const bool active = runtime_trace_enabled_ && power != trace_.verticalConnectionValues.end() && power->second; @@ -1385,6 +1473,7 @@ void LogicEditorWidget::rebuildScene() connection, x, upper->centerY, lower->centerY, active)); } + // 业务选择先转换成矩形集合,最后用一个顶层覆盖图元统一绘制 std::vector selection_rectangles; for (const std::string &rung_id : selected_row_ids_) { @@ -1481,6 +1570,7 @@ void LogicEditorWidget::rebuildScene() kRowHeight); } } + // 空选择不创建覆盖层,减少无意义场景图元 if (!selection_rectangles.empty()) { scene_->addItem(new SelectionOverlayItem( @@ -1491,6 +1581,7 @@ void LogicEditorWidget::rebuildScene() // 清除节点、网格、输出、竖线和整行选择集合 void LogicEditorWidget::clearObjectSelection() { + // 这些集合表示可复制删除的显式对象,光标行列状态在调用方按需保留 selected_node_ids_.clear(); selected_cells_.clear(); selected_output_rung_ids_.clear(); @@ -1502,6 +1593,7 @@ void LogicEditorWidget::clearObjectSelection() // 移除与其他显式选择冲突的节点选择 void LogicEditorWidget::synchronizeSelectedNodes() { + // 节点 ID 是属性面板视角的派生选择,源数据是格选择和输出选择 selected_node_ids_.clear(); for (const auto &position : selected_cells_) { @@ -1525,6 +1617,7 @@ void LogicEditorWidget::synchronizeSelectedNodes() // 汇总选择状态并通知属性面板当前唯一节点 void LogicEditorWidget::notifySelectionChanged() { + // 属性面板只展示一个节点,多选时使用视觉排序后的第一个节点 emit nodeSelected(selected_node_ids_.empty() ? QString{} : QString::fromStdString(selected_node_ids_.front())); } @@ -1533,6 +1626,7 @@ void LogicEditorWidget::notifySelectionChanged() void LogicEditorWidget::selectObject( const Hit &hit, bool extend_node_selection) { + // 行号选择与格、输出、竖线选择互斥,对应整行剪贴板模式 if (hit.rowHeader) { if (!extend_node_selection) @@ -1546,6 +1640,7 @@ void LogicEditorWidget::selectObject( { clearObjectSelection(); } + // Ctrl 点击已选行执行取消选择,否则把该行加入集合 const auto selected = std::find( selected_row_ids_.begin(), selected_row_ids_.end(), hit.rungId); if (extend_node_selection && selected != selected_row_ids_.end()) @@ -1556,6 +1651,7 @@ void LogicEditorWidget::selectObject( { selected_row_ids_.push_back(hit.rungId); } + // 按模型视觉行排序,让复制和状态提示顺序稳定 std::sort( selected_row_ids_.begin(), selected_row_ids_.end(), [this](const std::string &left, const std::string &right) @@ -1574,6 +1670,7 @@ void LogicEditorWidget::selectObject( } selected_row_ids_.clear(); + // 未按 Ctrl 时先清空旧对象选择,普通单击始终产生单选 if (!extend_node_selection) { clearObjectSelection(); @@ -1595,12 +1692,14 @@ void LogicEditorWidget::selectObject( selected_rung_id_ = hit.rungId; selected_column_ = hit.column; + // 光标状态可以落在空格,显式对象集合则只记录非 Gap 业务对象 selected_cell_ = hit.column >= 0 && !hit.boundary && !hit.output && !hit.vertical; selected_output_ = hit.output; selected_boundary_ = hit.boundary || hit.vertical; if (hit.vertical) { + // 同一套 Ctrl 切换规则分别应用于竖线、输出和条件格 const auto selected = std::find( selected_vertical_connection_ids_.begin(), selected_vertical_connection_ids_.end(), @@ -1645,6 +1744,7 @@ void LogicEditorWidget::selectObject( selected_cells_.push_back(position); } } + // 单段删除入口使用最后选择的竖线,完整多选集合仍用于批量删除和复制 selected_vertical_connection_id_ = selected_vertical_connection_ids_.empty() ? std::string{} : selected_vertical_connection_ids_.back(); @@ -1666,14 +1766,18 @@ void LogicEditorWidget::selectObjectsInBand( { clearObjectSelection(); } + // 橡皮框是视口像素矩形,mapToScene 把四角转换成场景多边形 const QPolygonF scene_polygon = mapToScene(viewport_rect.normalized()); + // normalized 修正用户从右下向左上拖动时出现的负宽高 QPainterPath selection_path; selection_path.addPolygon(scene_polygon); + // 闭合路径后 scene->items 才能按一个有面积的选择区域判断相交 selection_path.closeSubpath(); // 使用图元 shape 而不是外接矩形,细竖线的扩宽命中路径也能参与选择 const QList items = scene_->items( selection_path, Qt::IntersectsItemShape, + // DescendingOrder 先返回上层图元,但下面仍按稳定 ID 去重 Qt::DescendingOrder); for (QGraphicsItem *item : items) { @@ -1714,6 +1818,7 @@ void LogicEditorWidget::selectObjectsInBand( } } + // 框选结果按行列重新排序,摆脱 QGraphicsScene 返回顺序影响 std::sort( selected_cells_.begin(), selected_cells_.end(), @@ -1731,6 +1836,7 @@ void LogicEditorWidget::selectObjectsInBand( { return rowAt(left) < rowAt(right); }); + // 用框中心命中设置后续编辑光标,但显式选择仍保留框内全部对象 const Hit center_hit = hitAt(mapToScene(viewport_rect.center())); if (!center_hit.rungId.empty()) { @@ -1757,10 +1863,12 @@ void LogicEditorWidget::beginGesture(const Hit &hit) { return; } + // 手势开始只记录状态,不调用服务也不改变工程修改标记 gesture_active_ = true; gesture_origin_ = hit; gesture_current_ = hit; gesture_scene_position_valid_ = false; + // 请求重绘视口,drawForeground 会在下一帧画临时预览 viewport()->update(); } @@ -1772,6 +1880,7 @@ void LogicEditorWidget::updateGesture( { return; } + // 命中结构决定预览是否合法,原始场景坐标用于绘制非法方向提示线 gesture_current_ = hit; gesture_scene_position_ = scene_position; gesture_scene_position_valid_ = true; @@ -1785,6 +1894,7 @@ void LogicEditorWidget::finishGesture(const Hit &hit) { return; } + // 先复制起点再清空成员,后续服务失败也不会残留半完成手势 const Hit origin = gesture_origin_; gesture_active_ = false; gesture_origin_ = {}; @@ -1796,6 +1906,8 @@ void LogicEditorWidget::finishGesture(const Hit &hit) return; } LogicEditorResult result; + // UI 不分别维护“新增”和“删除”两套流程,只把目标状态传给服务层 + // Draw 的目标状态是已连接,Erase 的目标状态是未连接 const bool connected = mouse_wire_mode_ == MouseWireMode::Draw; if ((origin.vertical || origin.boundary) && (hit.vertical || hit.boundary)) @@ -1808,6 +1920,7 @@ void LogicEditorWidget::finishGesture(const Hit &hit) } else if (origin.column != hit.column) { + // 起点和终点不在同一列边界,说明用户斜着拖动,不能生成竖线 result = {false, LogicEditorError::InvalidOperation, "竖线拖动必须保持在同一列边界", {}}; } @@ -1840,7 +1953,7 @@ void LogicEditorWidget::finishGesture(const Hit &hit) } else if (origin.rungId == hit.rungId) { - // 同一行横向拖动时一次设置经过范围内的横线或空白格 + // 横向拖动不关心从左向右还是从右向左,服务层会整理列的先后顺序 result = editor_service_.setHorizontalWireRange( logic_id_, origin.rungId, origin.column, hit.column, connected); @@ -1861,6 +1974,7 @@ void LogicEditorWidget::finishGesture(const Hit &hit) selected_boundary_ = false; rebuildScene(); notifySelectionChanged(); + // 服务已经负责保存撤销快照,UI 这里只按最新模型重画画布 emit graphChanged(); } } @@ -1877,6 +1991,7 @@ void LogicEditorWidget::clearGesture() gesture_current_ = {}; gesture_scene_position_ = {}; gesture_scene_position_valid_ = false; + // 前景预览不属于 scene 图元,刷新 viewport 即可清掉 viewport()->update(); } @@ -1889,6 +2004,7 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const return; } + // 局部枚举只描述本次预览方向,不进入领域模型 enum class PreviewKind { Invalid, Horizontal, Vertical }; PreviewKind kind = PreviewKind::Invalid; bool valid = false; @@ -1927,14 +2043,17 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const } } + // 蓝色表示将要连接,橙色表示将要删除,红色表示当前释放会失败 QColor preview_color = valid ? connected ? QColor(QStringLiteral("#277da1")) : QColor(QStringLiteral("#c56a1a")) : kFaultColor; preview_color.setAlpha(220); QPen preview_pen(preview_color, valid ? 2.8 : 2.4, Qt::DashLine); + // cosmetic 保证视图缩放后虚线仍保持相同屏幕粗细 preview_pen.setCosmetic(true); preview_pen.setCapStyle(Qt::SquareCap); + // save/restore 把预览画笔状态限制在本函数,不污染 QGraphicsView 后续绘制 painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(preview_pen); @@ -1947,6 +2066,7 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const { const int first = std::min(origin.column, current.column); const int last = std::max(origin.column, current.column); + // 逐格预览会跳过节点,和服务层不覆盖指令节点的规则保持一致 for (int column = first; column <= last; ++column) { const LadderCell *cell = editor_service_.findCell( @@ -1967,6 +2087,7 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const } else if (valid && kind == PreviewKind::Vertical) { + // 竖向预览直接连接起止行中心,提交时服务再拆成相邻连接段 const RowLayout *first = layoutForRung(origin.rungId); const RowLayout *last = layoutForRung(current.rungId); if (first != nullptr && last != nullptr) @@ -1980,6 +2101,7 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const } else { + // 非法方向仍从起点画到真实鼠标位置,帮助用户看出错误手势 const RowLayout *layout = layoutForRung(origin.rungId); if (layout != nullptr && gesture_scene_position_valid_) { @@ -1992,6 +2114,7 @@ void LogicEditorWidget::drawGesturePreview(QPainter *painter) const gesture_scene_position_); } } + // 恢复进入函数前的画笔、画刷和渲染选项 painter->restore(); } @@ -2003,10 +2126,12 @@ void LogicEditorWidget::showCommandEditor(const Hit &hit) { return; } + // cancelCommandInput 会清临时状态,因此先保存并联插入所需的当前节点集合 const std::vector parallel_node_ids = selectedNodeIds(); cancelCommandInput(); command_target_.rungId = hit.rungId; command_target_.column = hit.column; + // 有稳定对象 ID 时优先按现有节点编辑,否则根据位置判断新增目标类型 const LogicNode *existing = hit.objectId.empty() ? nullptr : editor_service_.findNode(logic_id_, hit.objectId); if (existing != nullptr) @@ -2038,6 +2163,7 @@ void LogicEditorWidget::showCommandEditor(const Hit &hit) command_editor_->setToolTip( tr("触点使用 M 地址,比较和数据指令使用 D 地址")); command_editor_->setText(initial_text); + // 输入框先显示再定位,确保 raise 和焦点操作作用于可见原生控件 command_editor_->setVisible(true); QPointF center; if (!findCommandTargetCenter(command_target_, ¢er)) @@ -2046,6 +2172,7 @@ void LogicEditorWidget::showCommandEditor(const Hit &hit) return; } positionCommandInput(center); + // raise 把输入框提升到 viewport 其他子控件上方 command_editor_->raise(); command_editor_->setFocus(Qt::MouseFocusReason); command_editor_->selectAll(); @@ -2058,14 +2185,17 @@ void LogicEditorWidget::commitCommandInput() { return; } + // 请求只携带纯值和稳定 ID,命令服务负责解析、校验和原子提交 LogicCommandRequest request; request.logicId = logic_id_; request.text = command_editor_->text().trimmed().toStdString(); request.target = command_target_; request.parallelNodeIds = command_parallel_node_ids_; + // execute 失败时模型保持不变,输入框留在原处供用户直接修正 const LogicCommandResult result = command_service_.execute(request); if (!result.succeeded) { + // 临时样式只标红当前输入框,成功或取消时会清空 command_editor_->setStyleSheet( QStringLiteral("QLineEdit { border: 2px solid #c5362e; }")); command_editor_->setToolTip(QString::fromStdString(result.message)); @@ -2079,6 +2209,7 @@ void LogicEditorWidget::commitCommandInput() command_editor_->setToolTip( tr("触点使用 M 地址,比较和数据指令使用 D 地址")); command_parallel_node_ids_.clear(); + // 修改现有节点通常没有下一光标,连续新建指令才会继续推进 if (!result.hasNextCursor) { selectNode(result.id); @@ -2089,6 +2220,7 @@ void LogicEditorWidget::commitCommandInput() moveToCursor(result.nextCursor); emit graphChanged(); + // 服务返回领域光标,UI 再转换成下一次输入使用的命令目标 command_target_ = commandTargetForCursor(result.nextCursor); QPointF center; if (command_target_.rungId.empty() @@ -2110,6 +2242,7 @@ void LogicEditorWidget::cancelCommandInput() { command_editor_->clear(); command_editor_->setStyleSheet(QString{}); + // 隐藏而不 delete,下一次双击直接复用同一个输入框和补全器 command_editor_->setVisible(false); } command_target_ = {}; @@ -2123,9 +2256,11 @@ void LogicEditorWidget::positionCommandInput(const QPointF &scene_center) { return; } + // mapFromScene 把场景逻辑坐标转换成 viewport 子控件使用的像素坐标 const QPoint view_center = mapFromScene(scene_center); const int width = 360; const int height = 38; + // 收缩四像素作为安全边距,输入框不会贴到视口边缘或越界 const QRect bounds = viewport()->rect().adjusted(4, 4, -4, -4); const int left = std::clamp( view_center.x() - width / 2, @@ -2135,6 +2270,7 @@ void LogicEditorWidget::positionCommandInput(const QPointF &scene_center) view_center.y() - height / 2, bounds.top(), std::max(bounds.top(), bounds.bottom() - height + 1)); + // setGeometry 同时设置 QWidget 在 parent 中的位置和固定显示尺寸 command_editor_->setGeometry(left, top, width, height); } @@ -2143,6 +2279,7 @@ bool LogicEditorWidget::findCommandTargetCenter( const LogicCommandTarget &target, QPointF *scene_center) const { + // 输出参数允许调用方复用 QPointF,空指针时立即拒绝避免解引用崩溃 if (scene_center == nullptr) { return false; @@ -2176,6 +2313,7 @@ bool LogicEditorWidget::findCommandTargetCenter( layout->centerY); return true; } + // 现有条件节点按稳定表达式 ID 在该行十个格中重新定位 const auto found = std::find_if( rung->cells.cbegin(), rung->cells.cend(), [&target](const LadderCell &cell) @@ -2187,6 +2325,7 @@ bool LogicEditorWidget::findCommandTargetCenter( { return false; } + // distance 把迭代器位置转换为从零开始的列下标 column = static_cast(std::distance(rung->cells.cbegin(), found)); } if (column < 0 || column >= ProjectLimits::kMaximumConditionColumns) @@ -2211,6 +2350,7 @@ LogicCommandTarget LogicEditorWidget::commandTargetForCursor( target.kind = LogicCommandTargetKind::Output; return target; } + // 当前格可能在模型变化后失效,空指针会按 GapColumn 交给服务再次校验 const LadderCell *cell = editor_service_.findCell( logic_id_, cursor.rungId, cursor.column); if (cell != nullptr && cell->node.has_value()) @@ -2236,6 +2376,7 @@ LogicEditCursor LogicEditorWidget::conditionInsertionCursor() const return {}; } const std::string rung_id = currentRungId(); + // 输出槽光标保持输出语义,不尝试回退到最后一个条件格 if (selected_output_) { return {rung_id, ProjectLimits::kMaximumConditionColumns, true}; @@ -2245,6 +2386,7 @@ LogicEditCursor LogicEditorWidget::conditionInsertionCursor() const int target_column = selected_column_; const LadderCell *cell = editor_service_.findCell( logic_id_, rung_id, selected_column_); + // 在已有节点上继续插入时推进一列,避免覆盖当前节点 if (cell != nullptr && cell->kind == LadderCellKind::Node) { ++target_column; @@ -2257,6 +2399,7 @@ LogicEditCursor LogicEditorWidget::conditionInsertionCursor() const const LadderRung *rung = editor_service_.findRung(logic_id_, rung_id); if (rung != nullptr) { + // 没有明确格光标时,默认选择当前行第一个 Gap const auto empty = std::find_if( rung->cells.cbegin(), rung->cells.cend(), [](const LadderCell &cell) @@ -2283,6 +2426,7 @@ void LogicEditorWidget::moveToCursor(const LogicEditCursor &cursor) selected_cell_ = !cursor.output; selected_output_ = cursor.output; selected_boundary_ = false; + // 先重建选择覆盖层,再滚动到新光标,用户能立即看到推进结果 rebuildScene(); notifySelectionChanged(); @@ -2304,6 +2448,7 @@ void LogicEditorWidget::moveToCursor(const LogicEditCursor &cursor) void LogicEditorWidget::moveToVerticalTarget( const LogicVerticalEditResult &result) { + // F12 竖线操作推进到下一行时保留原来的格、输出或边界视觉语义 const bool keep_cell = selected_cell_; const bool keep_output = selected_output_; const bool keep_boundary = selected_boundary_; @@ -2321,6 +2466,7 @@ void LogicEditorWidget::moveToVerticalTarget( { return; } + // 默认把普通格光标放在列中心,边界和输出槽会在下面改写 qreal center_x = kLeftBus + (static_cast(result.columnBoundary) + 0.5) * kCellWidth; qreal target_width = kCellWidth; @@ -2353,6 +2499,7 @@ LogicEditorResult LogicEditorWidget::finishCursorEdit( reportFailure(result.edit); return result.edit; } + // 服务提交成功后才采用下一光标,失败时原选择和模型保持不变 moveToCursor(result.nextCursor); emit graphChanged(); return result.edit; @@ -2361,12 +2508,14 @@ LogicEditorResult LogicEditorWidget::finishCursorEdit( // 将编辑失败结果转换为界面错误信号 void LogicEditorWidget::reportFailure(const LogicEditorResult &result) { + // 画布不直接弹窗,只发信号让主窗口统一决定展示位置 emit editorError(QString::fromStdString(result.message)); } // 根据鼠标模式开始橡皮框选择或画线手势 void LogicEditorWidget::mousePressEvent(QMouseEvent *event) { + // QMouseEvent::pos 是 viewport 坐标,命中测试前必须转换为场景坐标 const Hit hit = hitAt(mapToScene(event->pos())); setFocus(Qt::MouseFocusReason); if (event->button() == Qt::LeftButton @@ -2379,6 +2528,7 @@ void LogicEditorWidget::mousePressEvent(QMouseEvent *event) { selection_pressed_ = true; selection_dragging_ = false; + // 橡皮框保存在 viewport 坐标,和 QRubberBand 的父对象坐标一致 selection_origin_ = event->pos(); selection_modifiers_ = event->modifiers(); selection_band_->setGeometry(QRect(selection_origin_, QSize{})); @@ -2386,9 +2536,11 @@ void LogicEditorWidget::mousePressEvent(QMouseEvent *event) } else { + // 非本类处理的按键交回基类,保留滚动、右键等默认行为 QGraphicsView::mousePressEvent(event); return; } + // accept 阻止同一次鼠标事件继续向父控件传播 event->accept(); } @@ -2397,6 +2549,8 @@ void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) { if (mouse_wire_mode_ == MouseWireMode::Select && selection_pressed_) { + // manhattanLength 计算快速,达到系统拖动阈值后才显示选择框 + // startDragDistance 来自系统样式设置,避免轻微手抖被当成框选 if (!selection_dragging_ && (event->pos() - selection_origin_).manhattanLength() >= QApplication::startDragDistance()) @@ -2406,12 +2560,14 @@ void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) } if (selection_dragging_) { + // QRubberBand 只负责视觉矩形,真正选择在鼠标释放时一次计算 selection_band_->setGeometry( QRect(selection_origin_, event->pos()).normalized()); } } else { + // 画线模式同时保存命中结果和连续鼠标场景位置用于前景预览 const QPointF scene_position = mapToScene(event->pos()); updateGesture(hitAt(scene_position), scene_position); } @@ -2425,6 +2581,7 @@ void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) if (event->button() == Qt::LeftButton && mouse_wire_mode_ == MouseWireMode::Select && selection_pressed_) { + // 使用按下时记录的修饰键,拖动过程中松开 Ctrl 不改变本次选择语义 const bool extend = selection_modifiers_.testFlag(Qt::ControlModifier); selection_pressed_ = false; selection_band_->hide(); @@ -2454,11 +2611,12 @@ void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) // 双击业务对象时打开内嵌指令编辑器 void LogicEditorWidget::mouseDoubleClickEvent(QMouseEvent *event) { + // 双击位置同样从 viewport 转场景,再由透明命中层恢复业务目标 showCommandEditor(hitAt(mapToScene(event->pos()))); event->accept(); } -// 处理退出画线模式和删除选择等键盘操作 +// 处理画布本地的 Escape,其他 QAction 快捷键继续交给主窗口分派 void LogicEditorWidget::keyPressEvent(QKeyEvent *event) { if (event != nullptr @@ -2470,6 +2628,7 @@ void LogicEditorWidget::keyPressEvent(QKeyEvent *event) event->accept(); return; } + // 未消费的按键交回基类,QAction 快捷键仍可继续匹配 QGraphicsView::keyPressEvent(event); } @@ -2477,6 +2636,7 @@ void LogicEditorWidget::keyPressEvent(QKeyEvent *event) void LogicEditorWidget::drawForeground( QPainter *painter, const QRectF &rect) { + // 先让基类完成默认前景,再叠加不会进入场景对象树的手势预览 QGraphicsView::drawForeground(painter, rect); drawGesturePreview(painter); } @@ -2484,6 +2644,7 @@ void LogicEditorWidget::drawForeground( // 视图尺寸变化后重新定位可见的内嵌输入框 void LogicEditorWidget::resizeEvent(QResizeEvent *event) { + // 基类先更新 viewport 尺寸和滚动条,后续位置换算才使用最新几何 QGraphicsView::resizeEvent(event); if (command_editor_ != nullptr && command_editor_->isVisible()) { @@ -2498,6 +2659,7 @@ void LogicEditorWidget::resizeEvent(QResizeEvent *event) // 滚动后同步内嵌输入框的视口位置 void LogicEditorWidget::scrollContentsBy(int dx, int dy) { + // 基类完成场景滚动后,viewport 子控件不会自动跟随场景目标 QGraphicsView::scrollContentsBy(dx, dy); if (command_editor_ != nullptr && command_editor_->isVisible()) { @@ -2509,13 +2671,14 @@ void LogicEditorWidget::scrollContentsBy(int dx, int dy) } } -// 过滤内嵌输入框的 Escape、Tab 和补全弹窗按键 +// 过滤内嵌输入框的 Escape 和失焦,Enter 由 returnPressed 提交 bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) { if (watched == command_editor_ && event != nullptr) { if (event->type() == QEvent::KeyPress) { + // 事件类型已确认是 KeyPress,static_cast 到 QKeyEvent 符合 Qt 契约 const auto *key_event = static_cast(event); if (key_event->key() == Qt::Key_Escape) { @@ -2526,6 +2689,7 @@ bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) else if (event->type() == QEvent::FocusOut) { // 延迟到焦点切换完成后判断,点击补全弹窗不会误关输入框 + // 零毫秒 singleShot 等当前焦点切换事件完整结束后再检查最终焦点 QTimer::singleShot( 0, this, @@ -2537,11 +2701,13 @@ bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) { return; } + // focusWidget 返回应用当前拥有键盘焦点的 QWidget QWidget *focus = QApplication::focusWidget(); QWidget *popup = command_completer_ == nullptr ? nullptr : command_completer_->popup(); if (popup != nullptr && popup->isVisible() && focus != nullptr + // 补全弹窗或它的内部视图获得焦点时继续保留命令输入 && (focus == popup || popup->isAncestorOf(focus))) { return; @@ -2550,6 +2716,7 @@ bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) }); } } + // 未处理事件必须交给基类过滤器,不能无条件吞掉输入框正常编辑 return QGraphicsView::eventFilter(watched, event); } @@ -2558,6 +2725,7 @@ LogicEditorResult LogicEditorWidget::addRung() { // 将新建网络操作交给编辑服务,在当前逻辑组末尾添加空行 const LogicEditorResult result = editor_service_.addRung(logic_id_); + // 只有服务层创建成功后才把光标切到新行并发出工程变化信号 if (result.succeeded) { clearObjectSelection(); @@ -2580,6 +2748,7 @@ LogicEditorResult LogicEditorWidget::addRung() // 在当前参考行之前或之后插入空白行 LogicEditorResult LogicEditorWidget::insertRung(bool after) { + // reference 使用稳定行 ID,插入造成 vector 移位也不会指错目标 const std::string reference = selected_rung_id_; // 没有参考行时追加末行,否则调用服务在选中行之前或之后插入 const LogicEditorResult result = reference.empty() @@ -2612,6 +2781,7 @@ LogicEditorResult LogicEditorWidget::deleteRung() { return {false, LogicEditorError::RungNotFound, "请先选择要删除的行", {}}; } + // 服务负责同步整理上下竖线,UI 不直接修改 rungs 和 connections 容器 const LogicEditorResult result = editor_service_.removeRung(logic_id_, rung_id); if (result.succeeded) { @@ -2653,6 +2823,7 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co reportFailure(result); return result; } + // 服务统一检查节点是否同一行且连续,并原子创建支路行和竖线 const LogicEditorResult result = editor_service_.addParallelBranch( logic_id_, selectedRungId(), selected_node_ids_, config, false); if (result.succeeded) @@ -2689,6 +2860,7 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire() reportFailure(result); return result; } + // 空逻辑交给服务在同一事务创建首行,非空逻辑使用当前插入光标 LogicEditCursor cursor = logic->rungs.empty() ? LogicEditCursor{} : conditionInsertionCursor(); if (cursor.output) @@ -2701,6 +2873,7 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire() reportFailure(result); return result; } + // 服务原子写入 Wire 并计算下一光标,Widget 只投影结果和刷新场景 return finishCursorEdit(editor_service_.applyWireAndAdvance( logic_id_, cursor)); } @@ -2716,11 +2889,13 @@ LogicEditorResult LogicEditorWidget::addVerticalWire() return {false, LogicEditorError::InvalidOperation, "请先选择一个列边界或网格,再插入竖线", {}}; } + // 结果同时包含编辑状态、是否真实改变模型以及下一行光标 const LogicVerticalEditResult result = editor_service_.applyVerticalConnectionAndAdvance( logic_id_, upper, selected_column_); if (result.edit.succeeded) { + // 已存在的连接也会推进光标,但只有真实新增连接时才通知模型变化 moveToVerticalTarget(result); if (result.changed) { @@ -2756,6 +2931,7 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire() logic_id_, rung_id, selected_column_, selected_column_, false); if (result.succeeded) { + // erase-remove 从显式选择集合中同步移除已经变成 Gap 的格 selected_cells_.erase( std::remove( selected_cells_.begin(), @@ -2781,6 +2957,7 @@ LogicEditorResult LogicEditorWidget::deleteVerticalWire() { return {false, LogicEditorError::ConnectionNotFound, "请选择要删除的竖线", {}}; } + // 批量接口即使这里只传一个 ID,也保持删除语义和多选删除一致 const LogicEditorResult result = editor_service_.removeVerticalConnections( logic_id_, {selected_vertical_connection_id_}); if (result.succeeded) @@ -2825,11 +3002,13 @@ LogicEditorResult LogicEditorWidget::setOutput( LogicClipboardPasteResult LogicEditorWidget::pasteClipboard( const LogicClipboardFragment &fragment) { + // pasteTarget 只提供锚点,冲突、边界和数量上限都由服务先完整校验 LogicClipboardPasteResult result = editor_service_.pasteClipboard( logic_id_, fragment, pasteTarget()); if (result.edit.succeeded) { clearObjectSelection(); + // 服务返回新对象的稳定选择,粘贴后直接高亮实际创建的内容 selected_cells_ = result.selection.cells; selected_output_rung_ids_ = result.selection.outputRungIds; selected_vertical_connection_ids_ = @@ -2890,6 +3069,7 @@ LogicClipboardPasteResult LogicEditorWidget::pasteClipboard( // 原子删除当前显式选择的网格对象或完整行 LogicEditorResult LogicEditorWidget::deleteSelected() { + // 删除请求只包含明确选择,不把当前整行光标隐式当成整行删除 LogicSelectionDeleteRequest selection; selection.cells = selected_cells_; selection.outputRungIds = selected_output_rung_ids_; @@ -2919,6 +3099,7 @@ LogicEditorResult LogicEditorWidget::deleteSelected() return result; } + // 服务先验证全部目标再一次提交,任一非法对象都不会留下部分删除 const LogicEditorResult result = editor_service_.deleteSelection( logic_id_, selection); if (result.succeeded) diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 1140439..9bbc66f 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -111,6 +111,8 @@ signals: void editorError(const QString &message); protected: + // 鼠标画线的调用顺序:按下记录起点 -> 移动更新预览 -> 松开一次性提交 + // 前两步只改临时手势状态,只有 mouseReleaseEvent 会请求服务修改工程 // 根据鼠标按下位置开始选择或画线手势 void mousePressEvent(QMouseEvent *event) override; // 更新橡皮框选择或画线预览 @@ -119,7 +121,7 @@ protected: void mouseReleaseEvent(QMouseEvent *event) override; // 双击节点时打开对应指令配置 void mouseDoubleClickEvent(QMouseEvent *event) override; - // 处理删除、退出画线模式和内嵌指令快捷键 + // 处理画布本地 Escape,主窗口 QAction 负责其余编辑快捷键 void keyPressEvent(QKeyEvent *event) override; // 在场景前景层绘制尚未提交的画线预览 void drawForeground(QPainter *painter, const QRectF &rect) override; @@ -127,7 +129,7 @@ protected: void resizeEvent(QResizeEvent *event) override; // 滚动画布后同步内嵌指令编辑器位置 void scrollContentsBy(int dx, int dy) override; - // 过滤内嵌指令输入框的提交和取消按键 + // 过滤内嵌指令输入框的 Escape 和失焦取消 bool eventFilter(QObject *watched, QEvent *event) override; private: @@ -160,13 +162,13 @@ private: void reportFailure(const LogicEditorResult &result); // 返回当前光标或选择所在的网络行 std::string currentRungId() const; - // 将场景坐标解析为梯形图业务命中结果 + // 将鼠标所在的场景坐标翻译成“第几行、第几列、网格或边界” Hit hitAt(const QPointF &scene_position) const; - // 记录鼠标手势起点和初始选择 + // 记录画线起点,不修改横线、竖线或撤销历史 void beginGesture(const Hit &hit); - // 更新鼠标手势终点和前景预览 + // 记录鼠标当前经过的位置,供前景层显示蓝色、橙色或红色预览 void updateGesture(const Hit &hit, const QPointF &scene_position); - // 将完整鼠标手势一次性提交编辑服务 + // 根据起点和终点判断横线或竖线,并且只调用一次范围编辑 API void finishGesture(const Hit &hit); // 清除尚未提交的手势状态和预览 void clearGesture(); diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 65070ad..3f62f2c 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -65,6 +65,7 @@ namespace { +// Qt::UserRole 之后的编号专供业务数据使用,不会覆盖 QListWidgetItem 内置角色 // 输出列表用这些自定义角色保存语法问题的定位信息 constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1; constexpr int kSyntaxRungIdRole = Qt::UserRole + 2; @@ -75,6 +76,7 @@ constexpr int kUserRuntimeReconnectIntervalMs = 3000; // 判断事件目标或它的父控件是否正在编辑文本 bool isTextEditingObject(QObject *object) { + // qobject_cast 借助 Qt 元对象系统做安全向下转型,失败时返回 nullptr QWidget *widget = qobject_cast(object); while (widget != nullptr) { @@ -85,6 +87,7 @@ bool isTextEditingObject(QObject *object) { return true; } + // 事件目标可能是输入框内部子控件,需要沿 QWidget 父链继续识别 widget = widget->parentWidget(); } return false; @@ -93,10 +96,12 @@ bool isTextEditingObject(QObject *object) // 将工程名称整理为 Windows 可以使用的 JSON 文件名 QString suggestedProjectFileName(QString project_name) { + // trimmed 只去除名称首尾空白,不改变中间用于显示的空格 project_name = project_name.trimmed(); const QString invalid_characters = QStringLiteral("<>:\"/\\|?*"); for (QChar &character : project_name) { + // unicode 返回 UTF-16 码元,0x20 以下属于 Windows 文件名禁用控制字符 if (character.unicode() < 0x20U || invalid_characters.contains(character)) { character = QLatin1Char('_'); @@ -124,6 +129,7 @@ QString suggestedProjectFileName(QString project_name) project_name = QStringLiteral("未命名工程"); } + // section 只取首个点号前的主体,覆盖 CON.txt 这类保留设备名变体 const QString device_name = project_name.section(QLatin1Char('.'), 0, 0).toUpper(); // Windows 保留设备名不能直接作为文件名使用 const bool is_reserved_device_name = device_name == QStringLiteral("CON") @@ -142,9 +148,10 @@ QString suggestedProjectFileName(QString project_name) return project_name + QStringLiteral(".json"); } -// 判断按键是否属于主窗口统一处理的编辑快捷键 +// 判断按键是否属于需要由文本控件优先接管的编辑快捷键 bool isEditorShortcut(const QKeyEvent &event) { + // matches 按 Qt 平台标准键序列识别撤销、重做、复制和粘贴 return event.matches(QKeySequence::Undo) || event.matches(QKeySequence::Redo) || event.matches(QKeySequence::Copy) @@ -213,6 +220,7 @@ QString transitionErrorText(ModeTransitionError error) // 将 QString 按 UTF-8 转换为标准字符串 std::string toUtf8(const QString &value) { + // QByteArray 在当前作用域持有 UTF-8 缓冲区,构造 std::string 时按长度完整复制 const QByteArray bytes = value.toUtf8(); return std::string(bytes.constData(), static_cast(bytes.size())); } @@ -220,6 +228,7 @@ std::string toUtf8(const QString &value) // 将 UTF-8 标准字符串转换为 QString QString fromUtf8(const std::string &value) { + // 显式传入字节数,字符串内容即使包含零字节也不会被提前截断 return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -265,16 +274,20 @@ QToolButton *addToolbarMenu( const QIcon &icon, const QList &actions) { + // 传入 toolbar 作为 QObject 父对象,工具栏销毁时会自动释放按钮 auto *button = new QToolButton(toolbar); button->setObjectName(object_name); button->setText(text); button->setIcon(icon); button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + // InstantPopup 表示点击按钮任意区域都直接打开菜单,不触发独立默认动作 button->setPopupMode(QToolButton::InstantPopup); + // 菜单归按钮所有,addActions 只挂接现有 QAction,不转移动作所有权 auto *menu = new QMenu(button); menu->addActions(actions); button->setMenu(menu); + // addWidget 会为按钮创建工具栏动作,并把按钮嵌入工具栏布局 toolbar->addWidget(button); return button; } @@ -283,6 +296,7 @@ QToolButton *addToolbarMenu( bool copyDirectoryContents( const QString &source_path, const QString &destination_path, QString *error) { + // QDir 同时负责路径拼接和目录枚举,不会因为构造对象而创建目录 QDir source_directory(source_path); if (!source_directory.exists()) { @@ -293,6 +307,7 @@ bool copyDirectoryContents( return false; } QDir destination_directory(destination_path); + // mkpath 会递归创建缺失的父目录,返回 false 表示最终目录不可用 if (!destination_directory.exists() && !QDir().mkpath(destination_path)) { @@ -303,6 +318,7 @@ bool copyDirectoryContents( return false; } + // 排除点目录并让子目录优先,保证递归复制时目标层级先建立 const QFileInfoList entries = source_directory.entryInfoList( QDir::NoDotAndDotDot | QDir::AllEntries, QDir::DirsFirst | QDir::Name); @@ -318,6 +334,7 @@ bool copyDirectoryContents( } continue; } + // QFile::copy 不会覆盖已有文件,这里先检查以给出统一失败语义 if (QFileInfo::exists(destination) || !QFile::copy(entry.absoluteFilePath(), destination)) { @@ -337,6 +354,7 @@ QStringList runtimeDependencyDirectories() QStringList directories; const auto appendDirectory = [&directories](const QString &directory) { + // absolutePath 将相对候选目录规范成便于去重和后续拼接的绝对路径 const QString normalized = QDir(directory).absolutePath(); if (!normalized.isEmpty() && !directories.contains(normalized)) { @@ -344,7 +362,9 @@ QStringList runtimeDependencyDirectories() } }; + // applicationDirPath 指向当前 exe 所在目录,发布环境通常优先从这里取依赖 appendDirectory(QCoreApplication::applicationDirPath()); + // QLibraryInfo 返回当前 Qt 运行时记录的工具链目录 appendDirectory(QLibraryInfo::location(QLibraryInfo::BinariesPath)); const QString qt_prefix = QLibraryInfo::location(QLibraryInfo::PrefixPath); if (!qt_prefix.isEmpty()) @@ -352,6 +372,8 @@ QStringList runtimeDependencyDirectories() appendDirectory(QDir(qt_prefix).filePath( QStringLiteral("../../Tools/mingw810_64/bin"))); } + // qgetenv 返回原始环境变量字节,Windows PATH 按当前本地编码转为 QString + // listSeparator 根据平台选择分隔符,Windows 下为分号 const QStringList path_directories = QString::fromLocal8Bit( qgetenv("PATH")) .split(QDir::listSeparator(), Qt::SkipEmptyParts); @@ -368,6 +390,7 @@ QString findRuntimeDependency( { for (const QString &directory : directories) { + // filePath 使用 QDir 规则拼接,避免手写正反斜杠 const QString candidate = QDir(directory).filePath(file_name); if (QFileInfo(candidate).isFile()) { @@ -381,6 +404,7 @@ QString findRuntimeDependency( QString findRuntimePluginDirectory( const QString &plugin_directory, const QStringList &directories) { + // 先检查 exe 旁已部署的插件,便于从发布包再次导出运行版 const QString application_directory = QCoreApplication::applicationDirPath(); const QString application_candidate = QDir(application_directory).filePath( @@ -390,6 +414,7 @@ QString findRuntimePluginDirectory( return application_candidate; } + // PluginsPath 是 Qt 安装记录的平台插件根目录 const QString qt_plugins = QLibraryInfo::location(QLibraryInfo::PluginsPath); const QString qt_candidate = QDir(qt_plugins).filePath(plugin_directory); if (QFileInfo(qt_candidate).isDir()) @@ -426,6 +451,7 @@ MainWindow::MainWindow( bool user_runtime_mode, QWidget *parent) : QMainWindow(parent), + // Ui::MainWindow 只包装 uic 生成的控件指针,由主窗口明确持有其包装对象 ui_(std::make_unique()), runtime_mode_service_(runtime_mode_service), project_service_(project_service), @@ -439,6 +465,7 @@ MainWindow::MainWindow( plc_discovery_gateway_(plc_discovery_gateway), application_settings_result_(application_settings_result), user_runtime_mode_(user_runtime_mode), + // 此构造入口没有外部导航服务,因此用 unique_ptr 保证服务与窗口同寿命 owned_hmi_navigation_service_( std::make_unique(project_service)), hmi_navigation_service_(owned_hmi_navigation_service_.get()), @@ -477,6 +504,7 @@ MainWindow::MainWindow( plc_discovery_gateway_(plc_discovery_gateway), application_settings_result_(application_settings_result), user_runtime_mode_(user_runtime_mode), + // 此处只保存调用方服务地址,生命周期由 main.cpp 的依赖注入顺序保证 hmi_navigation_service_(&hmi_navigation_service), plc_configuration_(application_settings_result.settings.plcDefaults) { @@ -486,10 +514,12 @@ MainWindow::MainWindow( // 组装主界面控制器、编辑器和运行状态回调 void MainWindow::initializeUi() { + // setupUi 按 main_window.ui 创建子控件并把生成指针填入 ui_ ui_->setupUi(this); - // 在应用级拦截编辑快捷键,避免主窗口动作抢走输入控件的按键 + // 应用级 ShortcutOverride 会先于 QAction 快捷键触发,让文本输入有机会接管按键 qApp->installEventFilter(this); configureAppearance(); + // 控制器通过回调读取当前会话 ID,避免复制一份可能过期的页面状态 property_panel_controller_ = std::make_unique( *this, *ui_, @@ -520,8 +550,10 @@ void MainWindow::initializeUi() configureRuntimeMonitor(); configureDataMonitor(); configureProjectTree(); + // 通信层回调只发刷新请求,真正的 QWidget 更新会排队回到主线程 runtime_mode_service_.setPlcStatusChangedCallback( [this] { schedulePlcStatusUpdate(); }); + // 默认页面和逻辑属于工程模型初始化,创建完后清空初始化产生的编辑历史 hmi_editor_service_.ensureDefaultPage(); logic_editor_service_.ensureDefaultLogic(); clearEditorHistories(); @@ -539,6 +571,7 @@ void MainWindow::initializeUi() const QString warning = fromUtf8( application_settings_result_.warningMessage); // 等主窗口进入事件循环后再显示启动警告 + // singleShot 的 context 为 this,窗口提前销毁时 Qt 会自动取消该回调 QTimer::singleShot( 0, this, @@ -566,6 +599,7 @@ void MainWindow::initializeUserRuntime() ui_->outputDock->setVisible(false); ui_->projectDock->setVisible(false); ui_->propertiesDock->setVisible(false); + // 定时器挂在主窗口对象树上,无需手动 delete user_runtime_reconnect_timer_ = new QTimer(this); user_runtime_reconnect_timer_->setInterval(kUserRuntimeReconnectIntervalMs); // 串口断开后由定时器调用重连入口,不要求用户重启程序 @@ -574,8 +608,10 @@ void MainWindow::initializeUserRuntime() &QTimer::timeout, this, &MainWindow::attemptUserRuntimeReconnect); + // 默认 CoarseTimer 允许小幅定时误差,并在定时器所属线程的事件循环触发 user_runtime_reconnect_timer_->start(); // 延迟到界面初始化完成后启动导航并自动连接 PLC + // 零毫秒 singleShot 把启动动作放到当前事件初始化完成后的下一轮事件循环 QTimer::singleShot( 0, this, @@ -598,6 +634,7 @@ void MainWindow::initializeUserRuntime() {}, ApplicationMode::Editing, runtime_mode_service_.plcConnectionState()); + // 主窗口继续作为服务和控制器宿主,仅隐藏普通编程器外壳 hide(); // 使用 runtime.ini 提供的串口参数自动连接真实 PLC @@ -654,6 +691,7 @@ void MainWindow::reportUserRuntimePlcFailure(const std::string &error) { return; } + // 以运行监控窗口为父对象,告警框会显示在用户当前可见窗口之上 QMessageBox::warning( runtime_monitor_widget_, tr("PLC 通信异常"), @@ -664,7 +702,9 @@ void MainWindow::reportUserRuntimePlcFailure(const std::string &error) // 注销由主窗口安装的全局回调和事件过滤器 MainWindow::~MainWindow() { + // 析构开始时先解除应用级过滤器,避免事件继续进入半销毁的主窗口 qApp->removeEventFilter(this); + // 这两个 std::function 回调捕获了 this,必须在窗口销毁前清空 register_monitor_service_.setAddressesChangedCallback({}); runtime_mode_service_.setPlcStatusChangedCallback({}); } @@ -672,12 +712,14 @@ MainWindow::~MainWindow() // 让文本输入控件优先处理撤销、复制等编辑快捷键 bool MainWindow::eventFilter(QObject *watched, QEvent *event) { + // ShortcutOverride 发生在 Qt 查找匹配 QAction 之前 if (event->type() == QEvent::ShortcutOverride && isTextEditingObject(watched)) { auto *key_event = static_cast(event); if (isEditorShortcut(*key_event)) { + // 接受事件并停止继续分派,按键随后由当前文本控件正常处理 event->accept(); return true; } @@ -690,6 +732,7 @@ void MainWindow::closeEvent(QCloseEvent *event) { if (!confirmSaveBeforeDestructiveAction()) { + // ignore 取消本次系统关闭请求,窗口和事件循环继续保留 event->ignore(); return; } @@ -697,19 +740,23 @@ void MainWindow::closeEvent(QCloseEvent *event) { runtime_panel_controller_->closeForApplicationExit(); } + // 交回基类完成默认接受关闭、隐藏窗口和退出判定 QMainWindow::closeEvent(event); } // 连接菜单、工具栏和编辑器动作 void MainWindow::configureActions() { + // QActionGroup 只管理互斥关系,三个 QAction 本身仍由 .ui 创建的父对象管理 mode_action_group_ = new QActionGroup(this); + // exclusive 保证编辑、离线和真机三个可选动作最多只有一个被选中 mode_action_group_->setExclusive(true); mode_action_group_->addAction(ui_->editingModeAction); mode_action_group_->addAction(ui_->offlineModeAction); mode_action_group_->addAction(ui_->onlineModeAction); // 三个模式菜单只提交目标模式,校验、停旧模式和切仓库统一由 requestMode 处理 + // 以 this 为接收上下文,主窗口销毁时这些连接会由 Qt 自动断开 connect(ui_->editingModeAction, &QAction::triggered, this, [this] { requestMode(ApplicationMode::Editing); }); connect(ui_->offlineModeAction, &QAction::triggered, this, @@ -724,6 +771,7 @@ void MainWindow::configureActions() // 用户点击“导出用户运行程序”后进入完整的运行版导出链路 connect(ui_->exportRuntimeAction, &QAction::triggered, this, &MainWindow::exportRuntimeProgram); + // .ui 快捷键、菜单和工具栏都会发出 triggered,统一进入同一组业务槽 connect(ui_->undoAction, &QAction::triggered, this, &MainWindow::undoActiveEditor); connect(ui_->redoAction, &QAction::triggered, @@ -748,6 +796,7 @@ void MainWindow::configureActions() { return; } + // item::data 按自定义角色取回 appendOutputMessage 保存的定位字段 const std::string logic_id = toUtf8( item->data(kSyntaxLogicIdRole).toString()); const std::string rung_id = toUtf8( @@ -756,6 +805,7 @@ void MainWindow::configureActions() { return; } + // 双击输出项只需要稳定 ID 和列,显示用的行号不参与模型定位 focusLogicSyntaxLocation({ logic_id, rung_id, @@ -782,12 +832,14 @@ void MainWindow::configureActions() [this] { addHmiControl(HmiControlType::AlarmList); }); connect(ui_->deleteControlAction, &QAction::triggered, this, &MainWindow::deleteSelectedControl); + // 动态动作以主窗口为父对象,菜单和工具栏只引用它们而不负责释放 align_left_action_ = new QAction(tr("左对齐"), this); align_horizontal_center_action_ = new QAction(tr("水平居中"), this); align_right_action_ = new QAction(tr("右对齐"), this); align_top_action_ = new QAction(tr("顶部对齐"), this); align_vertical_center_action_ = new QAction(tr("垂直居中"), this); align_bottom_action_ = new QAction(tr("底部对齐"), this); + // 保存返回按钮是为了随选择数量同步启用状态 hmi_layout_button_ = addToolbarMenu( ui_->hmiToolBar, tr("布局"), @@ -871,6 +923,7 @@ void MainWindow::configureActions() ? LogicEditorWidget::MouseWireMode::Draw : LogicEditorWidget::MouseWireMode::Select); }); + // F11/F12 只负责触发动作,选区校验、模型修改和光标推进仍由编辑器链路完成 connect(ui_->insertHorizontalWireAction, &QAction::triggered, this, &MainWindow::addLogicHorizontalWire); connect(ui_->insertVerticalWireAction, &QAction::triggered, @@ -887,6 +940,7 @@ void MainWindow::configureActions() RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}); }); + // 并联动作的菜单由主窗口持有,菜单返回的 QAction 由菜单自身管理 QMenu *parallel_menu = new QMenu(this); QAction *parallel_open = parallel_menu->addAction(tr("并联常开触点")); QAction *parallel_closed = parallel_menu->addAction(tr("并联常闭触点")); @@ -932,10 +986,13 @@ void MainWindow::configureActions() ComparisonOperator::Equal, 0}); }); + // QAction 绑定菜单后,菜单栏或工具栏都能从同一动作展开选项 ui_->parallelInsertAction->setMenu(parallel_menu); + // widgetForAction 返回工具栏为该 QAction 创建的实际按钮,其他容器可能返回 nullptr if (QToolButton *parallel_button = qobject_cast( ui_->logicToolBar->widgetForAction(ui_->parallelInsertAction))) { + // MenuButtonPopup 保留主区域默认动作,只有箭头区域展开并联类型菜单 parallel_button->setPopupMode(QToolButton::MenuButtonPopup); } connect(ui_->addNormallyOpenAction, &QAction::triggered, @@ -1073,6 +1130,7 @@ void MainWindow::configureActions() ui_->addSubAction, ui_->addCompareAction}); + // currentChanged 在程序调用 setCurrentIndex 时也会触发,不只响应鼠标切页 connect(ui_->editorTabWidget, &QTabWidget::currentChanged, this, [this](int index) @@ -1100,6 +1158,7 @@ void MainWindow::configureActions() ui_->hmiToolBar->setVisible(ui_->editorTabWidget->currentIndex() == 0); ui_->logicToolBar->setVisible(ui_->editorTabWidget->currentIndex() == 1); + // toggleViewAction 是停靠窗自带动作,勾选状态会自动跟随显示和隐藏 ui_->viewMenu->addAction(ui_->projectDock->toggleViewAction()); ui_->viewMenu->addAction(ui_->propertiesDock->toggleViewAction()); ui_->viewMenu->addAction(ui_->outputDock->toggleViewAction()); @@ -1112,7 +1171,9 @@ void MainWindow::configureActions() // 设置主窗口工具栏、状态栏和停靠面板的外观 void MainWindow::configureAppearance() { + // 允许同一区域的多个停靠窗组成嵌套布局 setDockNestingEnabled(true); + // setCorner 决定底部停靠区与左右停靠区相交时由哪一侧占据角落 setCorner(Qt::BottomLeftCorner, Qt::LeftDockWidgetArea); setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea); @@ -1172,6 +1233,7 @@ void MainWindow::configureAppearance() ui_->editorTabWidget->setTabIcon(1, makeUiIcon(UiIcon::Logic)); ui_->outputDock->setMaximumHeight(220); + // 状态标签进入主窗口 QObject 树,statusBar 只负责重新布局 mode_status_label_ = new QLabel(this); mode_status_label_->setObjectName(QStringLiteral("modeStatusLabel")); mode_status_label_->setMinimumWidth(88); @@ -1182,10 +1244,12 @@ void MainWindow::configureAppearance() executor_status_label_ = new QLabel(this); executor_status_label_->setObjectName(QStringLiteral("executorStatusLabel")); executor_status_label_->setMinimumWidth(132); + // permanentWidget 固定在状态栏右侧,不会被临时 showMessage 文本遮掉 statusBar()->addPermanentWidget(mode_status_label_); statusBar()->addPermanentWidget(register_status_label_); statusBar()->addPermanentWidget(executor_status_label_); + // 主窗口样式表会级联到子控件,带对象名的选择器只命中特定占位控件 setStyleSheet(QStringLiteral( "QMainWindow { background: #f3f5f7; }" "QToolBar { background: #ffffff; border: 0; border-bottom: 1px solid #cbd2d9;" @@ -1210,8 +1274,11 @@ void MainWindow::configureAppearance() // 创建 HMI 编辑画布并连接选择变化回调 void MainWindow::configureHmiEditor() { + // 占位框的布局来自 .ui,动态画布直接加入原有布局以保持 Designer 结构 QLayout *layout = ui_->hmiCanvasPlaceholder->layout(); + // 删除占位提示后 Qt 会同时把它从父对象和布局中移除 delete ui_->hmiEmptyLabel; + // 指定占位框为 parent 后,HmiEditorWidget 由 Qt 对象树自动释放 hmi_editor_widget_ = new HmiEditorWidget( hmi_editor_service_, hmi_runtime_service_, @@ -1226,11 +1293,13 @@ void MainWindow::configureLogicEditor() { QLayout *layout = ui_->logicCanvasPlaceholder->layout(); delete ui_->logicEmptyLabel; + // 梯形图画布仍然只负责交互和投影,模型修改继续委托编辑服务 logic_editor_widget_ = new LogicEditorWidget( logic_editor_service_, ui_->logicCanvasPlaceholder); logic_editor_widget_->setObjectName(QStringLiteral("logicEditorWidget")); logic_editor_widget_->setMinimumHeight(320); layout->addWidget(logic_editor_widget_); + // 两个画布都创建后再绑定,属性控制器不会接触半初始化对象 property_panel_controller_->bindEditorWidgets( *hmi_editor_widget_, *logic_editor_widget_); connect( @@ -1244,6 +1313,7 @@ void MainWindow::configureLogicEditor() // 创建运行监控面板并连接 HMI 导航和返回编辑态操作 void MainWindow::configureRuntimeMonitor() { + // 控制器集中持有运行窗口的显示、刷新和退出流程,主窗口只提供协调回调 runtime_panel_controller_ = std::make_unique( *this, runtime_mode_service_, @@ -1283,7 +1353,9 @@ void MainWindow::configureRuntimeMonitor() requestMode(ApplicationMode::Editing); } }); + // configure 完成独立运行窗口及其内部信号的创建 runtime_panel_controller_->configure(); + // RuntimeMonitorWidget 的所有权仍在控制器,这里只缓存非拥有指针便于状态同步 runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget(); connect( runtime_monitor_widget_, @@ -1292,6 +1364,7 @@ void MainWindow::configureRuntimeMonitor() [this](int mode) { // 运行大屏的模式下拉框也复用主窗口的统一切换流程 + // 信号为跨 UI 边界的 int,进入主窗口后恢复成领域枚举 requestMode(static_cast(mode)); }); } @@ -1299,12 +1372,15 @@ void MainWindow::configureRuntimeMonitor() // 创建编辑态数据监控页并连接数据源切换回调 void MainWindow::configureDataMonitor() { + // 监控页签作为 parent,Qt 会在页签销毁时释放动态监控控件 data_monitor_widget_ = new FreeMonitorWidget( register_monitor_service_, ui_->dataMonitorTab); data_monitor_widget_->setObjectName(QStringLiteral("dataMonitorWidget")); data_monitor_widget_->setEditingSourceSelectorVisible(true); + // addWidget 接管布局位置,但 QObject 生命周期仍由 parent 关系决定 ui_->dataMonitorLayout->addWidget(data_monitor_widget_); + // 地址变化后立即重建 PLC 轮询集合,使编辑页和运行页读取同一批地址 connect(data_monitor_widget_, &FreeMonitorWidget::monitorAddressesChanged, this, [this] @@ -1323,6 +1399,7 @@ void MainWindow::configureDataMonitor() }); connect(data_monitor_widget_, &FreeMonitorWidget::editingSourceChanged, this, &MainWindow::refreshDataMonitorUi); + // 该服务回调不是 Qt 信号,析构函数中必须主动清空捕获 this 的函数对象 register_monitor_service_.setAddressesChangedCallback( [this] { @@ -1339,18 +1416,22 @@ void MainWindow::configureDataMonitor() register_monitor_service_.pollAddresses(), register_monitor_service_.multiWordRanges()); }); + // 新监控地址提交前先让运行模式服务验证并应用真实 PLC 轮询配置 register_monitor_service_.setPollConfigurationValidator( [this](const std::vector &addresses, const std::vector &ranges) { const PlcCommunicationResult result = runtime_mode_service_.setMonitorAddresses(addresses, ranges); + // 空字符串代表验证通过,失败时把服务层原因原样交回监控控件 return result.succeeded ? std::string{} : result.message; }); + // 150 ms 定时刷新只读取仓库缓存,不在 UI 线程直接等待串口 data_monitor_refresh_timer_ = new QTimer(this); data_monitor_refresh_timer_->setInterval(150); connect(data_monitor_refresh_timer_, &QTimer::timeout, this, &MainWindow::refreshDataMonitorUi); + // start 后 timeout 由主线程事件循环投递,窗口繁忙时允许合并或延后 data_monitor_refresh_timer_->start(); refreshDataMonitorUi(); } @@ -1364,16 +1445,19 @@ void MainWindow::refreshDataMonitorUi() } const ApplicationMode mode = runtime_mode_service_.mode(); const PlcConnectionState plc_state = runtime_mode_service_.plcConnectionState(); + // 服务据此判断编辑态专用的数据源选择是否参与实际读写 register_monitor_service_.setEditingModeActive( mode == ApplicationMode::Editing); const bool editing_plc_connected = mode == ApplicationMode::Editing && plc_state != PlcConnectionState::Disconnected && plc_state != PlcConnectionState::Faulted; + // 已连接和首读完成分别传入,避免串口刚打开时误放行真机写入 register_monitor_service_.setEditingPlcState( editing_plc_connected, editing_plc_connected && runtime_mode_service_.initialPlcReadCompleted()); const bool online_connected = mode == ApplicationMode::OnlineRunning && plc_state == PlcConnectionState::Connected; + // 写权限由模式、用户选择的数据源和真机就绪状态共同决定 data_monitor_widget_->setWriteEnabled( (mode == ApplicationMode::Editing && (register_monitor_service_.editingSource() @@ -1388,6 +1472,7 @@ void MainWindow::refreshDataMonitorUi() // 创建工程树控制器并连接页面和逻辑切换回调 void MainWindow::configureProjectTree() { + // 控制器引用当前页面和逻辑 ID,树选择变化会直接更新主窗口会话状态 project_workspace_controller_ = std::make_unique( *this, *ui_, @@ -1421,6 +1506,7 @@ void MainWindow::configureProjectTree() { updateEditActions(); }); + // configure 连接工程树信号并完成第一次动作状态初始化 project_workspace_controller_->configure(); } @@ -1445,7 +1531,9 @@ void MainWindow::configureAlarms() this, [this] { + // 栈上对话框在 exec 返回后自动析构,this 让窗口居中并保持层级 AlarmConfigurationDialog dialog(alarm_editor_service_, this); + // exec 启动局部模态事件循环,关闭前阻止用户操作主窗口 dialog.exec(); refreshProjectUi(); }); @@ -1460,6 +1548,7 @@ void MainWindow::configureRegisterComments() this, [this] { + // 对话框通过服务提交注释,关闭后画布重新读取最新寄存器说明 RegisterCommentDialog dialog(register_comment_service_, this); dialog.exec(); logic_editor_widget_->reloadLogic(); @@ -1470,12 +1559,14 @@ void MainWindow::configureRegisterComments() // 撤销当前标签页中最后一次编辑操作 void MainWindow::undoActiveEditor() { + // 运行态统一禁止工程编辑,快捷键不能绕过界面动作的可用策略 if (!runtime_mode_service_.policy().allowsProjectEditing) { return; } if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) { + // 服务层撤销会原子恢复模型,失败时 UI 保持当前投影不动 if (!hmi_editor_service_.undo().succeeded) { return; @@ -1488,6 +1579,7 @@ void MainWindow::undoActiveEditor() } else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) { + // currentWidget 用真实页对象判断,避免依赖可能随 Designer 调整的索引 if (!logic_editor_service_.undo().succeeded) { return; @@ -1510,6 +1602,7 @@ void MainWindow::redoActiveEditor() } if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) { + // 重做成功后清空旧选择,因为恢复的模型对象集合可能已经变化 if (!hmi_editor_service_.redo().succeeded) { return; @@ -1538,6 +1631,7 @@ void MainWindow::redoActiveEditor() // 将当前标签页选中的 HMI 控件或梯形图片段写入内部剪贴板 void MainWindow::copyActiveSelection() { + // 文本焦点下的复制由输入控件处理,不读取画布对象选择 if (!runtime_mode_service_.policy().allowsProjectEditing || isTextEditingObject(qApp->focusWidget())) { @@ -1547,6 +1641,7 @@ void MainWindow::copyActiveSelection() { const std::vector ids = hmi_editor_widget_->selectedControlIds(); std::vector controls; + // reserve 只预留容量,不创建控件对象,避免循环复制时反复扩容 controls.reserve(ids.size()); for (const std::string &id : ids) { @@ -1561,6 +1656,7 @@ void MainWindow::copyActiveSelection() { return; } + // std::move 把完整值对象转入内部剪贴板,不保留模型对象指针 editor_clipboard_ = HmiClipboardData{std::move(controls), 0}; statusBar()->showMessage(tr("已复制 %1 个 HMI 控件").arg(ids.size()), 3000); } @@ -1577,6 +1673,7 @@ void MainWindow::copyActiveSelection() const std::size_t object_count = result.fragment.cells.size() + result.fragment.outputs.size() + result.fragment.verticalConnections.size(); + // variant 同一时刻只保存一种编辑器剪贴板,切换页签不会错误粘贴 editor_clipboard_ = LogicClipboardData{std::move(result.fragment)}; if (whole_rows) { @@ -1598,6 +1695,7 @@ void MainWindow::copyActiveSelection() // 将内部剪贴板内容粘贴到对应编辑器 void MainWindow::pasteActiveSelection() { + // 文本焦点下的粘贴由输入控件处理,不消费内部画布剪贴板 if (!runtime_mode_service_.policy().allowsProjectEditing || isTextEditingObject(qApp->focusWidget())) { @@ -1611,6 +1709,7 @@ void MainWindow::pasteActiveSelection() { return; } + // 连续粘贴逐次偏移,std::min 把最大偏移限制在 200 像素 const int offset = 20 * std::min(data->pasteCount + 1, 10); const HmiEditorResult result = hmi_editor_service_.pasteControls( current_hmi_page_id_, data->controls, offset, offset); @@ -1622,6 +1721,7 @@ void MainWindow::pasteActiveSelection() ++data->pasteCount; refreshProjectUi(); hmi_editor_widget_->reloadPage(); + // reloadPage 重建图元后再按服务返回的稳定 ID 恢复新控件选择 hmi_editor_widget_->selectControl(result.id); showControlProperties(result.id); statusBar()->showMessage(tr("已粘贴 HMI 控件"), 3000); @@ -1671,6 +1771,7 @@ void MainWindow::clearActiveSelection() { if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) { + // QGraphicsScene::clearSelection 会清掉所有图元选择并触发选择变化信号 hmi_editor_widget_->scene()->clearSelection(); selected_control_id_.clear(); showControlProperties({}); @@ -1697,6 +1798,7 @@ void MainWindow::clearActiveSelection() // 根据当前标签页和选择状态刷新编辑动作 void MainWindow::updateEditActions() { + // QAction::setEnabled 同时约束菜单、工具栏按钮和该动作注册的快捷键 const bool editable = runtime_mode_service_.policy().allowsProjectEditing; const bool hmi_active = ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab; const bool logic_active = @@ -1715,6 +1817,7 @@ void MainWindow::updateEditActions() && logic_editor_widget_->hasCopyableSelection(); ui_->copyAction->setEnabled( editable && (hmi_selection || logic_selection)); + // holds_alternative 只检查 variant 当前分支,不复制实际剪贴板内容 const bool hmi_clipboard = std::holds_alternative(editor_clipboard_); const bool logic_clipboard = std::holds_alternative(editor_clipboard_); @@ -1770,6 +1873,7 @@ void MainWindow::clearEditorHistories() // 刷新工程树、编辑画布、标题和相关动作状态 void MainWindow::refreshProjectUi() { + // 控制器一次刷新工程树和两个编辑器的会话投影 project_workspace_controller_->refresh(); updateEditActions(); updateWindowTitle(); @@ -1783,6 +1887,7 @@ void MainWindow::updateWindowTitle() { title += QStringLiteral("*"); } + // 星号由工程修改状态决定,不把磁盘路径混入用户可见工程标题 setWindowTitle(title); } @@ -1795,6 +1900,7 @@ bool MainWindow::confirmSaveBeforeDestructiveAction() } const QString project_name = fromUtf8(project_service_.project().metadata.name); + // 静态 warning 返回用户实际点击的标准按钮,默认焦点设为保存 const QMessageBox::StandardButton choice = QMessageBox::warning( this, tr("工程尚未保存"), @@ -1810,6 +1916,7 @@ bool MainWindow::confirmSaveBeforeDestructiveAction() return true; } + // 保存失败或用户在另存为窗口取消后 modified 仍为 true,因此继续阻止破坏性操作 saveProject(); return !project_service_.isModified(); } @@ -1835,12 +1942,14 @@ void MainWindow::connectPlc() return; } // 普通编程器先创建 PLC 配置对话框,让用户确认串口参数 + // 断开回调允许对话框在重新扫描或换配置前清理旧通信会话 PlcConnectionDialog dialog( plc_configuration_, plc_discovery_gateway_, [this] { runtime_mode_service_.disconnectPlc(); }, this); // 模态打开配置窗口,取消时不发起任何连接 + // exec 返回 Accepted 仅表示用户确认参数,连接是否成功仍以服务结果为准 if (dialog.exec() != QDialog::Accepted) { return; @@ -1881,12 +1990,14 @@ void MainWindow::disconnectPlc() // 将同一轮事件中的 PLC 状态通知合并为一次界面刷新 void MainWindow::schedulePlcStatusUpdate() { + // 多次通信通知在排队刷新执行前只保留一个,避免事件队列被高频状态淹没 if (plc_status_update_pending_) { return; } plc_status_update_pending_ = true; // 排队到主线程事件循环执行,避免通信回调直接更新界面 + // invokeMethod 的 functor 形式会把捕获任务投递到 this 所在线程 QMetaObject::invokeMethod( this, [this] @@ -1920,6 +2031,7 @@ void MainWindow::schedulePlcStatusUpdate() } updateModeUi(plcStatusText(runtime_mode_service_)); }, + // QueuedConnection 强制异步执行,确保所有 QWidget API 都在 GUI 线程调用 Qt::QueuedConnection); } @@ -1957,6 +2069,7 @@ void MainWindow::alignSelectedHmiControls(HmiAlignment alignment) } const std::vector control_ids = hmi_editor_widget_->selectedControlIds(); + // 服务先校验全部 ID 再一次性提交,失败不会留下部分控件移动 const HmiEditorResult result = hmi_editor_service_.alignControls( current_hmi_page_id_, control_ids, alignment); if (!result.succeeded) @@ -1966,6 +2079,7 @@ void MainWindow::alignSelectedHmiControls(HmiAlignment alignment) } hmi_editor_widget_->reloadPage(); + // 画布重建后用稳定 ID 恢复原批量选择,方便连续执行不同对齐命令 hmi_editor_widget_->selectControls(control_ids); refreshProjectUi(); statusBar()->showMessage( @@ -2006,6 +2120,7 @@ void MainWindow::addLogicParallelBranch(const LogicNodeConfig &config) // 在当前梯形图单元格添加横向连线 void MainWindow::addLogicHorizontalWire() { + // Widget 收集当前光标,Service 原子写入横线并返回下一编辑位置 const LogicEditorResult result = logic_editor_widget_->addHorizontalWire(); if (!result.succeeded) { @@ -2021,6 +2136,7 @@ void MainWindow::addLogicHorizontalWire() // 在当前梯形图位置添加竖向连线 void MainWindow::addLogicVerticalWire() { + // Widget 收集当前列边界,Service 原子建立连接并返回下一视觉行 const LogicEditorResult result = logic_editor_widget_->addVerticalWire(); if (!result.succeeded) { @@ -2085,13 +2201,16 @@ void MainWindow::setLogicOutput(const LogicNodeConfig &config) // 打开指令参数对话框并提交配置完成的输出节点 void MainWindow::configureAndSetLogicOutput(const LogicNodeConfig &config) { + // config 是初始模板,variant 中的具体指令类型决定对话框展示哪些字段 LogicInstructionDialog dialog(config, this); if (dialog.exec() != QDialog::Accepted) { return; } + // 只有 Accepted 后才读取完整配置,取消不会修改梯形图模型 const LogicNodeConfig configured = dialog.config(); // 对话框确认后把完整输出参数提交给梯形图控件 + // true 表示配置已经由对话框确认,控件无需再次打开参数窗口 LogicEditorResult result = logic_editor_widget_->setOutput(configured, true); if (!result.succeeded) { @@ -2124,6 +2243,7 @@ void MainWindow::addLogicRung() // 在当前行上方插入空白行 void MainWindow::insertLogicRungAbove() { + // false 表示以当前行为基准向上插入,连接整理规则由服务层处理 const LogicEditorResult result = logic_editor_widget_->insertRung(false); if (!result.succeeded) { @@ -2139,6 +2259,7 @@ void MainWindow::insertLogicRungAbove() // 在当前行下方插入空白行 void MainWindow::insertLogicRungBelow() { + // true 表示向下插入,不在 UI 侧手动计算目标行索引 const LogicEditorResult result = logic_editor_widget_->insertRung(true); if (!result.succeeded) { @@ -2182,6 +2303,7 @@ void MainWindow::editSelectedNetworkComment() return; } bool accepted = false; + // getText 用 accepted 区分取消和确认空文本,空文本是合法的清除注释操作 const QString comment = QInputDialog::getText( this, tr("网络注释"), @@ -2238,12 +2360,15 @@ void MainWindow::reportLogicSyntaxCheck( .arg(result.removedWireCells) .arg(result.removedVerticalConnections); } + // location 作为 item 自定义数据保存,双击输出消息时可反向定位模型 appendOutputMessage(message, result.location); + // show 确保关闭的停靠栏重新显示,raise 把它切到同区域叠放窗口最前面 ui_->outputDock->show(); ui_->outputDock->raise(); statusBar()->showMessage(message, 6000); if (!result.valid && result.location.has_value()) { + // optional 已确认有值后解引用,定位第一个阻止运行的语法问题 focusLogicSyntaxLocation(*result.location); } updateEditActions(); @@ -2259,6 +2384,7 @@ void MainWindow::focusLogicSyntaxLocation( return; } current_logic_id_ = location.logicId; + // setCurrentWidget 会同步触发 currentChanged,从而切换工具栏和属性面板 ui_->editorTabWidget->setCurrentWidget(ui_->logicEditorTab); refreshProjectUi(); logic_editor_widget_->focusSyntaxLocation( @@ -2291,6 +2417,7 @@ void MainWindow::createNewProject() name_dialog.setLabelText(tr("工程名称")); name_dialog.setInputMode(QInputDialog::TextInput); + // findChild 从 QInputDialog 的 QObject 子树中取得标准按钮盒以控制确认按钮 QDialogButtonBox *button_box = name_dialog.findChild(); QPushButton *ok_button = button_box != nullptr ? button_box->button(QDialogButtonBox::Ok) @@ -2298,6 +2425,7 @@ void MainWindow::createNewProject() if (ok_button != nullptr) { ok_button->setEnabled(false); + // 连接上下文也是 name_dialog,对话框析构后 Qt 自动解除 lambda 连接 connect(&name_dialog, &QInputDialog::textValueChanged, &name_dialog, @@ -2322,6 +2450,7 @@ void MainWindow::createNewProject() clearEditorHistories(); selected_control_id_.clear(); selected_logic_node_id_.clear(); + // monostate 把 variant 恢复为空剪贴板,避免跨工程粘贴旧模型值 editor_clipboard_ = std::monostate{}; current_hmi_page_id_ = project_service_.project().initialHmiPageId; current_logic_id_ = logic_editor_service_.firstLogicId(); @@ -2355,6 +2484,7 @@ void MainWindow::saveProjectAs() { const QString suggested_file_name = suggestedProjectFileName( fromUtf8(project_service_.project().metadata.name)); + // 静态文件对话框返回空字符串表示用户取消,不应当作保存错误提示 const QString path = QFileDialog::getSaveFileName( this, tr("工程另存为"), @@ -2382,12 +2512,14 @@ void MainWindow::loadProject() { return; } + // 过滤器只影响可见候选文件,严格 JSON 版本校验仍由工程存储层完成 const QString path = QFileDialog::getOpenFileName( this, tr("加载工程"), {}, tr("工程文件 (*.json)")); if (path.isEmpty()) { return; } + // 服务在完整解析和校验成功后才替换当前工程,失败时旧工程保持不变 const ProjectOperationResult result = project_service_.load(toUtf8(path)); if (!result.succeeded) { @@ -2429,6 +2561,7 @@ void MainWindow::exportRuntimeProgram() QString suggested_name = suggestedProjectFileName( fromUtf8(project_service_.project().metadata.name)); suggested_name.chop(QStringLiteral(".json").size()); + // DontResolveSymlinks 保留用户选择的目录路径,不额外改写为链接目标路径 const QString parent_directory = QFileDialog::getExistingDirectory( this, tr("选择用户运行程序的目标父目录"), @@ -2451,6 +2584,7 @@ void MainWindow::exportRuntimeProgram() { return; } + // chopped 返回移除已知后缀后的副本,不修改用户输入变量 const QString output_name = suggestedProjectFileName(requested_name).chopped( QStringLiteral(".json").size()); const QString destination_directory = QDir(parent_directory).filePath(output_name); @@ -2475,6 +2609,7 @@ void MainWindow::exportRuntimeProgram() { return; } + // removeRecursively 会删除整个旧导出目录,只在用户明确确认覆盖后调用 if (!QDir(destination_directory).removeRecursively()) { showProjectResult( @@ -2486,6 +2621,7 @@ void MainWindow::exportRuntimeProgram() } // 先导出临时工程文件,封装完成或失败后统一删除 + // tempPath 选择系统临时目录,进程 ID 用于降低并行导出文件名冲突概率 const QString temp_project = QDir::tempPath() + QStringLiteral("/qtproxinje-runtime-") + QString::number(QCoreApplication::applicationPid()) @@ -2499,6 +2635,7 @@ void MainWindow::exportRuntimeProgram() return; } + // 空取消按钮文本表示该同步导出流程不提供中途取消入口 QProgressDialog progress( tr("正在准备导出用户运行程序…"), QString(), @@ -2506,23 +2643,29 @@ void MainWindow::exportRuntimeProgram() 100, this); progress.setWindowTitle(tr("导出用户运行程序")); + // WindowModal 只阻止操作当前主窗口,不影响应用的其他顶层窗口 progress.setWindowModality(Qt::WindowModal); progress.setAutoClose(false); progress.setAutoReset(false); + // minimumDuration 为零使短任务也立即显示进度,避免用户误以为未响应 progress.setMinimumDuration(0); progress.setValue(5); progress.show(); // 导出步骤同步执行,阶段切换时主动处理事件以便及时刷新进度窗口 + // processEvents 只用于绘制阶段进度,实际文件操作仍然同步执行 QApplication::processEvents(); ui_->exportRuntimeAction->setEnabled(false); const QString destination_executable = QDir(destination_directory).filePath( output_name + QStringLiteral(".exe")); + // applicationFilePath 返回当前正在运行的 exe,作为封装运行版的二进制模板 const QString template_executable = QCoreApplication::applicationFilePath(); // 任一步骤失败都清理临时文件和未完成的目录,并恢复导出操作状态 + // lambda 引用的局部对象都覆盖整个同步导出函数作用域,不会逃逸保存 const auto failExport = [this, &progress, &temp_project, &destination_directory]( const QString &message) { + // QFile::remove 和 removeRecursively 的清理失败不覆盖最初导出错误 QFile::remove(temp_project); QDir(destination_directory).removeRecursively(); progress.setValue(0); @@ -2532,6 +2675,7 @@ void MainWindow::exportRuntimeProgram() showProjectResult(tr("导出用户运行程序"), message, false); }; + // QDir().mkpath 接受完整路径并递归创建导出目录 if (!QDir().mkpath(destination_directory)) { failExport(tr("无法创建导出目录:%1").arg(destination_directory)); @@ -2540,6 +2684,7 @@ void MainWindow::exportRuntimeProgram() progress.setLabelText(tr("正在封装工程数据…")); progress.setValue(35); + // 主动处理一次事件让新的标签和值在耗时封装前显示出来 QApplication::processEvents(); // 以当前程序为模板,将运行版工程数据封装进新的可执行文件 const RuntimeProjectBundleWriteResult bundle_result = @@ -2555,6 +2700,7 @@ void MainWindow::exportRuntimeProgram() progress.setLabelText(tr("正在复制 Qt 运行库和平台插件…")); progress.setValue(65); + // 复制依赖前刷新进度窗口,不在这里等待或轮询 PLC QApplication::processEvents(); // 从程序目录、Qt 安装目录和 PATH 中查找运行版所需的动态库 const QStringList dependency_directories = runtimeDependencyDirectories(); @@ -2574,6 +2720,7 @@ void MainWindow::exportRuntimeProgram() const QString source = findRuntimeDependency( runtime_file, dependency_directories); const QString destination = QDir(destination_directory).filePath(runtime_file); + // QFile::copy 要求目标文件不存在,目标目录在本轮刚创建时满足该约束 if (source.isEmpty() || !QFile::copy(source, destination)) { failExport( @@ -2608,6 +2755,7 @@ void MainWindow::exportRuntimeProgram() progress.setLabelText(tr("正在写入运行版串口配置…")); progress.setValue(85); + // 写配置前把 85% 阶段同步到界面 QApplication::processEvents(); // 运行版启动后使用该配置自动连接 PLC std::string settings_error; @@ -2624,6 +2772,7 @@ void MainWindow::exportRuntimeProgram() } // 重新读取导出的 exe,确认其中的工程数据能够正常加载 + // load 会验证尾部魔数、版本、长度和摘要,避免只检查文件是否存在 const RuntimeProjectBundleLoadResult verification = RuntimeProjectBundleService::load(destination_executable); if (verification.status != RuntimeProjectBundleStatus::Loaded) @@ -2638,6 +2787,7 @@ void MainWindow::exportRuntimeProgram() // 所有运行文件齐全且封装可读后才向用户报告导出完成 progress.setLabelText(tr("导出完成")); progress.setValue(100); + // 最后处理一次事件,确保 100% 状态在关闭对话框前完成绘制 QApplication::processEvents(); progress.close(); ui_->exportRuntimeAction->setEnabled( @@ -2653,6 +2803,7 @@ void MainWindow::showProjectResult( const QString &action, const QString &message, bool succeeded) { const QString output = action + QStringLiteral(": ") + message; + // showMessage 显示五秒临时文本,不会覆盖右侧 permanentWidget 状态标签 statusBar()->showMessage(output, 5000); appendOutputMessage(output); if (!succeeded) @@ -2670,11 +2821,14 @@ void MainWindow::appendOutputMessage( .settings.projectLimits.maximumOutputMessages; while (ui_->outputList->count() >= maximum) { + // takeItem 只从列表脱离条目而不释放内存,因此立即 delete delete ui_->outputList->takeItem(0); } + // 传入列表作为构造参数会立即插入条目,并由列表接管其生命周期 auto *item = new QListWidgetItem(message, ui_->outputList); if (location.has_value()) { + // setData 可在不可见的自定义角色中保存双击定位所需的稳定 ID item->setData( kSyntaxLogicIdRole, fromUtf8(location->logicId)); item->setData( @@ -2685,14 +2839,14 @@ void MainWindow::appendOutputMessage( .arg(location->row) .arg(location->column)); } + // 新消息插入后滚动到末尾,旧消息仍可由用户手动向上查看 ui_->outputList->scrollToBottom(); } // 请求切换运行模式并根据服务层结果同步界面 bool MainWindow::requestMode(ApplicationMode requested_mode) { - // UI 动作只提出目标模式,所有前置条件和仓库切换由 RuntimeModeService 决定 - // UI 仅转发模式意图,合法性由服务层和领域状态机决定 + // UI 只转发模式意图,合法性、执行器启停和仓库切换由服务层决定 ModeTransitionResult result; const ApplicationMode current_mode = runtime_mode_service_.mode(); if ((requested_mode == ApplicationMode::OfflineRunning @@ -2757,6 +2911,7 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) && (result.succeeded || result.error == ModeTransitionError::ProjectNotReady)) { + // lastSyntaxCheck 引用服务在本次运行请求中产生的检查结果,避免重复扫描 const LogicSyntaxCheckResult &syntax = runtime_mode_service_.lastSyntaxCheck(); if (syntax.completed && (syntax.changed || !syntax.valid)) @@ -2769,6 +2924,7 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) restoreCurrentModeAction(); if (runtime_monitor_widget_ != nullptr && runtime_request) { + // 切换失败后把大屏下拉框恢复成服务层仍然有效的真实模式 runtime_monitor_widget_->setMode( runtime_mode_service_.mode(), runtime_mode_service_.plcConnectionState()); @@ -2807,6 +2963,7 @@ void MainWindow::updateModeUi(const QString &message) // 按服务层策略统一启用/禁用编辑入口,避免单个按钮遗漏状态同步 const ApplicationMode mode = runtime_mode_service_.mode(); const ModePolicy policy = runtime_mode_service_.policy(); + // 仅编辑态允许重新捕获离线初始值,运行中保持扫描起点稳定 register_monitor_service_.setOfflineInitialCaptureEnabled( mode == ApplicationMode::Editing); restoreCurrentModeAction(); @@ -2815,6 +2972,7 @@ void MainWindow::updateModeUi(const QString &message) { if (hmi_navigation_service_->currentPageId().empty()) { + // 未启动导航时从工程 initialHmiPageId 建立运行页面会话 const HmiNavigationResult navigation = hmi_navigation_service_->start(); if (!navigation.succeeded) { @@ -2836,6 +2994,7 @@ void MainWindow::updateModeUi(const QString &message) mode, runtime_mode_service_.plcConnectionState()); } // 将同一份模式策略同步到所有可编辑入口,避免只禁用部分操作 + // setEnabled 会递归影响停靠窗内部子控件,防止运行态继续修改模型 ui_->projectDock->setEnabled(policy.allowsProjectEditing); ui_->propertiesDock->setEnabled(policy.allowsProjectEditing); hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); @@ -2889,6 +3048,7 @@ void MainWindow::updateModeUi(const QString &message) updateProjectTreeActions(); updateEditActions(); + // 状态文字和颜色都从服务真实模式生成,不使用用户刚点击的目标动作 mode_status_label_->setText(modeText(mode)); if (mode == ApplicationMode::Editing) { @@ -2941,6 +3101,7 @@ void MainWindow::updateModeUi(const QString &message) updateSimulationUi(false); refreshDataMonitorUi(); statusBar()->showMessage(message, 4000); + // 只去重相邻且完全相同的状态消息,其他历史顺序仍然保留 const bool duplicate = ui_->outputList->count() > 0 && ui_->outputList->item(ui_->outputList->count() - 1)->text() == message; if (!duplicate) diff --git a/app/src/ui/runtime_panel_controller.cpp b/app/src/ui/runtime_panel_controller.cpp index 06f3f74..115f184 100644 --- a/app/src/ui/runtime_panel_controller.cpp +++ b/app/src/ui/runtime_panel_controller.cpp @@ -153,7 +153,7 @@ void RuntimePanelController::configure() QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(), &OnlineLogicMonitorService::stateChanged, &parent_, [this] { handleSimulationStateChanged(); }); - // 真机每轮本地推算完成后复用同一入口刷新梯形图亮线 + // 真机推算服务只发布 LogicTraceSnapshot,控制器负责把它转交给 UI,不参与再次计算 QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(), &OnlineLogicMonitorService::scanCompleted, &parent_, [this] { handleScanCompleted(); }, @@ -388,6 +388,7 @@ void RuntimePanelController::handleScanCompleted() return; } const std::string logic_id = current_logic_id_(); + // 离线轨迹来自虚拟 M/D 的实际扫描,真机轨迹来自 PLC 缓存副本的本地推算 const LogicTraceSnapshot &trace = offline_running ? runtime_mode_service_.offlineSimulationService().traceSnapshot() : runtime_mode_service_.onlineLogicMonitorService().traceSnapshot(); @@ -395,7 +396,7 @@ void RuntimePanelController::handleScanCompleted() logic_editor_widget_.setRuntimeTrace(trace); if (runtime_monitor_widget_ != nullptr) { - // 将最新轨迹交给运行监控大屏绘制触点、横线、竖线和输出状态 + // 大屏只消费轨迹做亮线显示,不会因此产生任何寄存器写入 runtime_monitor_widget_->setLogicTrace(trace); } } diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md index dc8e6a9..0e54bcd 100644 --- a/docs/ai/handoff.md +++ b/docs/ai/handoff.md @@ -1,9 +1,13 @@ # 当前开发交接 -> 更新日期:2026-08-30。本文件只保留当前工作区、本轮改动、验证结果和后续人工检查项。 +> 更新日期:2026-08-31。本文件只保留当前工作区、本轮改动、验证结果和后续人工检查项。 ## 当前状态 +- `hmi_editor_widget.cpp` 和 `logic_editor_widget.cpp` 已面向初学者补充大量单行注释,重点说明 Qt 图形视图三套坐标、图元生命周期、绘制、命中测试、选择、鼠标手势、内嵌指令输入和服务层提交;`logic_editor_service.h/.cpp` 进一步补充鼠标横竖线范围 API、原子提交、回滚和单步撤销说明;未修改业务逻辑 +- `main_window.cpp` 已补充大量关键单行注释,说明 Qt 对象所有权、事件过滤、信号槽、定时器、排队调用、对话框、Model/View 自定义角色、工程文件和运行版导出 API;未修改业务逻辑 +- 真机本地梯形图推算链路已补充关键代码注释,明确完整 PLC 轮询边界、缓存到临时仓库的隔离、执行器输出落点、轨迹信号转交和画布投影,并强调该轨迹不是 PLC 内部真实程序轨迹 +- 新增 `docs/二次开发/工程新建保存加载与JSON说明.md`,按“业务规则 -> 核心调用链”说明新建、保存、加载的状态保护与严格 JSON `4.0` 读写;代码阅读清单已补充入口 - 测试程序已优化:功能测试入口按用例独立报告 `[PASS]/[FAIL]`,单个用例失败不会跳过同一目标的其他用例;HMI 数值运行测试拆出独立用例并去除重复原始字序断言;数量边界失败提示由统一上限常量生成,运行包夹具同步为工程格式 `4.0` - 性能测试已调整为合法上限扫描、超限压力扫描、4001 个 M 位边界访问和 256 个 D 字连续块访问四项基准 - HMI 编辑态支持基础控件布局对齐:左、水平居中、右、顶部、垂直居中、底部;至少选中两个控件后从 HMI 工具栏“布局”菜单执行,对齐只修改控件位置并支持单步撤销/重做 @@ -53,6 +57,9 @@ ## 验证结果 +- HMI 和梯形图画布注释补充后,Release All 全量测试通过,14 个功能测试目标共 135 个用例全部通过,4 项性能测试通过;本轮继续补充鼠标画线服务 API 注释,Release Functional 的 14 个目标、135 个用例再次全部通过,画布与服务文件重新参与编译,Release 主程序构建成功,`git diff --check` 通过,未连接真实 PLC +- `main_window.cpp` 注释补充后,Release All 全量测试通过,14 个功能测试目标共 135 个用例全部通过,4 项性能测试通过;Release 主程序重新编译成功,`git diff --check` 通过,未连接真实 PLC +- 本轮新增和索引业务文档,并为真机本地梯形图推算链路补充代码注释;未修改业务行为,已执行 Markdown UTF-8 读取检查、Release Functional 全量测试和 `git diff --check`,未连接真实 PLC - 14 个 Release Functional 测试目标全部通过,功能用例逐项输出结果:领域 18、设置 6、运行版设置 4、报警 2、HMI 编辑 12、逻辑编辑 27、离线仿真 19、工程管理 11、监控 7、运行包 1、运行模式 5、运行面板 13、PLC 对话框 3、PLC 运行时 7,共 135 个用例 - M 地址类型框联动修改后,14 个 Release Functional 测试目标重新运行并全部通过,Release 主程序构建成功 - Release Performance 四项基准全部通过:合法上限规模扫描约 21.75 ms/次,超限压力扫描约 1.09 ms/次,4001 个 M 位逐地址读写约 0.0479 ms/次,256 个 D 字连续块读写约 0.000062 ms/次;结果仅作为本机执行器和虚拟仓库基线 diff --git a/docs/二次开发/工程新建保存加载与JSON说明.md b/docs/二次开发/工程新建保存加载与JSON说明.md new file mode 100644 index 0000000..7f939f8 --- /dev/null +++ b/docs/二次开发/工程新建保存加载与JSON说明.md @@ -0,0 +1,171 @@ +# 工程新建、保存、加载与 JSON 说明 + +## 一、先说业务 + +程序同一时间只维护一个当前工程,并额外记录两项状态:当前工程关联的 JSON 路径,以及是否存在未保存修改。新建和加载会替换当前工程,所以操作前会先处理未保存内容;保存和加载只有完整成功后,才会更新这两项状态。 + +### 1. 新建工程 + +用户点击“新建工程”后,程序先检查当前工程有没有未保存修改: + +- 选“保存”:先完成保存,保存失败或取消另存为就停止新建 +- 选“不保存”:继续新建 +- 选“取消”:保持当前工程不动 + +工程名称会去掉首尾空白,空名称不能提交。新建成功后会生成新的工程 ID,格式版本固定为 `4.0`,原文件路径会被清空,工程标记为“未保存”。界面随后补上默认 HMI 页面和默认控制逻辑,并清空撤销记录、选择状态和剪贴板。 + +### 2. 保存与另存为 + +“保存”优先写回当前路径;新工程还没有路径时,会自动转到“另存为”。保存分三步: + +1. 先用 `Project::validate()` 检查工程能不能保存 +2. 把完整工程序列化为 JSON `4.0` +3. 先写临时文件,写完后再原子替换目标文件 + +只有文件提交成功,程序才会记录新路径并清除“未保存”标记。校验失败、文件打不开、写入不完整或提交失败时,当前路径和修改状态都保持原样,原目标文件也不会被半份新内容覆盖。 + +保存校验允许编辑草稿,例如未绑定控件、未完成的梯形图节点和断路 `Gap`。这些内容能保存,不等于能运行;进入运行态还要单独通过 `validateForRunning()`。 + +### 3. 加载工程 + +加载前同样先处理当前工程的未保存修改。选定文件后,新文件会先进入临时 `Project` 对象,依次经过: + +1. 文件可读和 16 MiB 大小上限检查 +2. JSON 语法检查,根节点必须是对象 +3. `formatVersion` 检查,只接受字符串 `"4.0"` +4. 必填字段、字段类型、枚举值和数组数量检查 +5. 页面、控件、报警、地址注释和梯形图的逐层解析 +6. `Project::validate()` 领域关系校验 + +上面任何一步失败,临时对象都会被丢弃,当前工程、当前文件路径和“未保存”状态不变。全部成功后才一次性替换当前工程,并把加载路径记为当前路径、清除“未保存”标记。界面再重置撤销记录、选择和剪贴板,当前 HMI 页面回到 `initialHmiPageId`。 + +### 4. 严格 JSON `4.0` 是什么意思 + +当前程序只读写一个版本,不做旧格式迁移,也不猜字段含义: + +- `1.0`、`2.0`、`3.0` 和未知版本直接拒绝 +- 顶层字段全部必填:`formatVersion`、`id`、`name`、`hmiPages`、`initialHmiPageId`、`alarmDefinitions`、`registerComments`、`controlLogics` +- 字段存在但类型不对也会失败,例如数组写成对象、整数写成小数、未知控件或指令枚举 +- 数组数量在解析时就受当前启动配置和工程硬上限约束 +- 字段解析通过后仍要检查 ID/名称唯一、引用目标存在、控件不越界、地址范围和占用关系等业务规则 +- 当前编辑页面、当前逻辑、撤销记录和寄存器运行值属于会话状态,不写入 JSON + +具体字段结构和每种控件、报警、梯形图节点的取值见 [工程 JSON 4.0 格式说明](../工程格式说明.md)。 + +### 5. 原子性落到哪里 + +| 保护对象 | 做法 | 失败后的结果 | +| --- | --- | --- | +| 当前工程 | 加载时先解析到局部 `Project` | 原工程不被半成品替换 | +| 目标 JSON | `QSaveFile` 先写临时文件,再 `commit()` | 原文件不会留下半截内容 | +| 文件路径和修改状态 | 服务层在存储成功后才更新 | 仍指向原路径,仍保持未保存状态 | +| 新建/加载前的未保存内容 | UI 统一调用保存确认 | 保存失败或取消时终止破坏性操作 | + +这里的“原子”指单次工程替换或文件提交不会只完成一半,不代表程序会自动替用户保存。 + +## 二、代码核心调用链路 + +### 1. 对象怎么接起来 + +```text +main() + -> JsonProjectStorage(projectLimits) + -> ProjectService(JsonProjectStorage, projectLimits) + -> MainWindow(ProjectService, ...) +``` + +`main.cpp` 负责依赖注入。`MainWindow` 只处理文件对话框、确认框和界面刷新,`ProjectService` 编排完整用例,`JsonProjectStorage` 负责 JSON 与文件系统,`Project::validate()` 负责领域规则。 + +### 2. 新建工程调用链 + +```text +QAction::triggered + -> MainWindow::createNewProject() + -> MainWindow::confirmSaveBeforeDestructiveAction() + -> MainWindow::saveProject() // 用户选择先保存时 + -> ProjectService::save()/saveAs() + -> ProjectService::createNewProject(name) + -> ProjectService::makeNewProject(name) + -> generateProjectId() + -> metadata.formatVersion = "4.0" + -> HmiEditorService::ensureDefaultPage() + -> LogicEditorService::ensureDefaultLogic() + -> MainWindow::clearEditorHistories() + -> MainWindow::refreshProjectUi() +``` + +核心状态变化发生在 `ProjectService::createNewProject()`:先校验名称,再整体替换 `project_`,清空 `current_file_path_`,并把 `modified_` 设为 `true`。 + +### 3. 保存调用链 + +```text +QAction::triggered + -> MainWindow::saveProject() + -> MainWindow::saveProjectAs() // 当前路径为空时 + -> ProjectService::save()/saveAs(filePath) + -> Project::validate(projectLimits) + -> JsonProjectStorage::save(project, filePath) + -> Project::validate(projectLimits) // 存储边界再次兜底 + -> 检查 formatVersion == "4.0" + -> serializeProject(project) + -> serializeHmiPage()/serializeHmiControl() + -> serializeAlarmDefinition() + -> serializeRegisterComment() + -> serializeControlLogic() + -> QJsonDocument::toJson(Indented) + -> QSaveFile::write() + -> QSaveFile::commit() + -> current_file_path_ = filePath + -> modified_ = false +``` + +`ProjectService` 和 `JsonProjectStorage` 都做保存校验:前者守住业务用例,后者守住存储接口,避免其他调用方绕过服务层写入非法工程。 + +### 4. 加载调用链 + +```text +QAction::triggered + -> MainWindow::loadProject() + -> MainWindow::confirmSaveBeforeDestructiveAction() + -> ProjectService::load(filePath) + -> JsonProjectStorage::load(filePath) + -> QFile::open()/read() + -> QJsonDocument::fromJson() + -> parseProject(root, projectLimits, &temporaryProject) + -> readString("formatVersion") + -> 检查 formatVersion == "4.0" + -> readString()/readArray() 读取顶层字段 + -> parseHmiPage()/parseAlarmDefinition() + -> parseRegisterComment()/parseControlLogic() + -> temporaryProject.validate(projectLimits) + -> 返回完整 ProjectLoadResult + -> result.project.validate(projectLimits) // 服务边界再次兜底 + -> project_ = std::move(result.project) + -> current_file_path_ = filePath + -> modified_ = false + -> MainWindow::clearEditorHistories() + -> MainWindow::refreshProjectUi() +``` + +`ParseState` 只保留第一个解析错误,并带上类似 `project.hmiPages[0]...` 的字段路径,界面最终通过 `ProjectOperationResult::message` 把具体原因提示给用户。 + +### 5. 关键类职责 + +| 类/结构 | 主要职责 | +| --- | --- | +| `MainWindow` | 接收 QAction、显示确认框和文件对话框、成功后重置编辑会话 | +| `ProjectService` | 保存当前工程、路径和修改状态,保证成功后才提交服务状态 | +| `ProjectStorage` | 定义保存和加载契约,隔离服务层与 Qt 文件实现 | +| `JsonProjectStorage` | 严格读写 JSON `4.0`,限制文件大小并用 `QSaveFile` 原子提交 | +| `Project` | 聚合工程数据,通过 `validate()` 检查可保存规则 | +| `ProjectOperationResult` | 向 UI 返回服务错误、存储错误和可读提示 | +| `ParseState` | 保存 JSON 解析遇到的第一个错误和字段位置 | + +### 6. 代码和测试入口 + +- UI 入口:`app/src/ui/main_window.cpp` +- 工程用例:`app/src/services/project_service.h/.cpp` +- 存储契约:`app/src/domain/project_storage.h` +- JSON 实现:`app/src/infrastructure/json_project_storage.h/.cpp` +- 工程校验:`app/src/domain/project_model.h/.cpp` +- 往返、旧版本拒绝、非法结构和状态保护测试:`app/tests/project_management_tests.cpp` diff --git a/docs/代码功能阅读清单.md b/docs/代码功能阅读清单.md index e4b230f..0c1ade0 100644 --- a/docs/代码功能阅读清单.md +++ b/docs/代码功能阅读清单.md @@ -208,7 +208,8 @@ HMI / 监控 / 软件执行器 2. `app/src/services/project_service.h/.cpp` 3. `app/src/infrastructure/json_project_storage.h/.cpp` 4. `docs/工程格式说明.md` -5. `app/tests/project_management_tests.cpp` +5. `docs/二次开发/工程新建保存加载与JSON说明.md` +6. `app/tests/project_management_tests.cpp` ### 重点调用链