| @@ -573,6 +573,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| } | |||
| if (poll_cycle_completed) | |||
| { | |||
| // 此信号表示本轮全部读块已更新,真机本地轨迹只能在这个一致快照边界上推算 | |||
| emit pollCycleCompleted(); | |||
| if (poll_cycle_completed_callback_) | |||
| { | |||
| @@ -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<std::pair<std::string, int>> &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<std::size_t>(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); | |||
| } | |||
| @@ -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<std::pair<std::string, int>> &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, | |||
| @@ -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_) | |||
| { | |||
| @@ -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<ControlLogic> &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<ControlLogic> logic_snapshot_; // 本次会话固定使用的逻辑快照 | |||
| std::vector<RegisterAddress> referenced_addresses_; // 梯形图实际引用的去重地址 | |||
| @@ -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 | |||
| @@ -189,39 +189,52 @@ void SoftwareLogicExecutor::resetRuntime() | |||
| } | |||
| // 按工程顺序执行一轮完整的软件逻辑扫描 | |||
| // 按工程顺序执行一轮完整的梯形图软件逻辑扫描(PLC周期仿真) | |||
| LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| const std::vector<ControlLogic> &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<bool> power(group_size, true); | |||
| // 逐列遍历梯形图(从左向右传播电源,boundary代表当前处理的列边界) | |||
| for (int boundary = 0; | |||
| boundary <= ProjectLimits::kMaximumConditionColumns; | |||
| ++boundary) | |||
| { | |||
| // 用并查集合并当前列边界上的竖线,避免反复沿上下支路递归查找 | |||
| // ========== 并查集:合并当前列边界上所有垂直竖线,构建梯级连通分量 ========== | |||
| // 父节点数组,用于并查集,每组梯级独立 | |||
| std::vector<std::size_t> 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<bool> 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<std::size_t>(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(); | |||
| } | |||
| @@ -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<ControlLogic> &logics, | |||
| @@ -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<std::chrono::seconds>( | |||
| 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<decltype(typed_value)>; | |||
| // if constexpr 只编译命中的类型分支,不会产生运行时类型判断 | |||
| if constexpr (std::is_same_v<Value, float>) | |||
| { | |||
| // max_digits10 保证浮点数转成文字后还能无损读回原值 | |||
| return QString::number( | |||
| static_cast<double>(typed_value), 'g', | |||
| std::numeric_limits<float>::max_digits10); | |||
| @@ -73,6 +78,7 @@ QString numericValueText(const RegisterNumericValue &value) | |||
| } | |||
| else | |||
| { | |||
| // 整数先扩成 qlonglong,统一调用 QString 的有符号整数重载 | |||
| return QString::number(static_cast<qlonglong>(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<double> requestFloatingPointInput( | |||
| ? std::numeric_limits<float>::max_digits10 | |||
| : std::numeric_limits<double>::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<double> requestFloatingPointInput( | |||
| has_current_value ? numericValueAsDouble(current_value) : 0.0, | |||
| 'g', digits)); | |||
| // QInputDialog 没有直接暴露验证器,通过内部输入框安装范围验证 | |||
| // findChild 从对话框的 QObject 子树里取得内部输入框 | |||
| QLineEdit *editor = dialog.findChild<QLineEdit *>(); | |||
| 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<double> 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<double>{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<std::size_t>(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<AlarmRecord> &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<double>(page_width_ - control_.bounds.width)); | |||
| const qreal maximum_y = std::max( | |||
| 0.0, static_cast<double>(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<int>((rect.height() - kAlarmHeaderHeight) | |||
| / kAlarmRowHeight)); | |||
| // 同时受控件实际高度和项目统一可见行上限约束 | |||
| return static_cast<std::size_t>(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<int>(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<int>(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<HmiGraphicsItem *>(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<std::string> &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<std::string> HmiEditorWidget::selectedControlIds() const | |||
| { | |||
| // selectedItems 的返回顺序不是视觉顺序,因此先保存位置再自行排序 | |||
| std::vector<std::pair<QPointF, std::string>> positioned_ids; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| @@ -1162,6 +1247,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||
| if (control_item != nullptr) | |||
| { | |||
| positioned_ids.emplace_back( | |||
| // scenePos 是经过所有父图元变换后的最终场景坐标 | |||
| control_item->scenePos(), control_item->controlId()); | |||
| } | |||
| } | |||
| @@ -1169,6 +1255,7 @@ std::vector<std::string> 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<std::string> HmiEditorWidget::selectedControlIds() const | |||
| return left.first.x() < right.first.x(); | |||
| }); | |||
| std::vector<std::string> 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<int>(std::lround(position.x())); | |||
| bounds.y = static_cast<int>(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<double> 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<std::int16_t>::max() | |||
| : std::numeric_limits<std::int32_t>::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) | |||
| @@ -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(); | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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/次;结果仅作为本机执行器和虚拟仓库基线 | |||
| @@ -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` | |||
| @@ -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` | |||
| ### 重点调用链 | |||