Procházet zdrojové kódy

docs: 完善工程说明与代码注释

main
suyu před 2 týdny
rodič
revize
ca12b7427c
16 změnil soubory, kde provedl 758 přidání a 48 odebrání
  1. +1
    -0
      app/src/infrastructure/plc_communication_service.cpp
  2. +11
    -1
      app/src/services/logic_editor_service.cpp
  3. +8
    -3
      app/src/services/logic_editor_service.h
  4. +6
    -4
      app/src/services/online_logic_monitor_service.cpp
  5. +7
    -6
      app/src/services/online_logic_monitor_service.h
  6. +3
    -2
      app/src/services/runtime_mode_service.cpp
  7. +71
    -12
      app/src/services/software_logic_executor.cpp
  8. +5
    -2
      app/src/services/software_logic_executor.h
  9. +104
    -0
      app/src/ui/hmi_editor_widget.cpp
  10. +185
    -4
      app/src/ui/logic_editor_widget.cpp
  11. +8
    -6
      app/src/ui/logic_editor_widget.h
  12. +165
    -4
      app/src/ui/main_window.cpp
  13. +3
    -2
      app/src/ui/runtime_panel_controller.cpp
  14. +8
    -1
      docs/ai/handoff.md
  15. +171
    -0
      docs/二次开发/工程新建保存加载与JSON说明.md
  16. +2
    -1
      docs/代码功能阅读清单.md

+ 1
- 0
app/src/infrastructure/plc_communication_service.cpp Zobrazit soubor

@@ -573,6 +573,7 @@ void PlcCommunicationService::pollNextBlock()
}
if (poll_cycle_completed)
{
// 此信号表示本轮全部读块已更新,真机本地轨迹只能在这个一致快照边界上推算
emit pollCycleCompleted();
if (poll_cycle_completed_callback_)
{


+ 11
- 1
app/src/services/logic_editor_service.cpp Zobrazit soubor

@@ -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);
}


+ 8
- 3
app/src/services/logic_editor_service.h Zobrazit soubor

@@ -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,


+ 6
- 4
app/src/services/online_logic_monitor_service.cpp Zobrazit soubor

@@ -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_)
{


+ 7
- 6
app/src/services/online_logic_monitor_service.h Zobrazit soubor

@@ -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_; // 梯形图实际引用的去重地址


+ 3
- 2
app/src/services/runtime_mode_service.cpp Zobrazit soubor

@@ -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


+ 71
- 12
app/src/services/software_logic_executor.cpp Zobrazit soubor

@@ -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();
}



+ 5
- 2
app/src/services/software_logic_executor.h Zobrazit soubor

@@ -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,


+ 104
- 0
app/src/ui/hmi_editor_widget.cpp Zobrazit soubor

@@ -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)


+ 185
- 4
app/src/ui/logic_editor_widget.cpp
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 8
- 6
app/src/ui/logic_editor_widget.h Zobrazit soubor

@@ -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();


+ 165
- 4
app/src/ui/main_window.cpp
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 3
- 2
app/src/ui/runtime_panel_controller.cpp Zobrazit soubor

@@ -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);
}
}

+ 8
- 1
docs/ai/handoff.md Zobrazit soubor

@@ -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/次;结果仅作为本机执行器和虚拟仓库基线


+ 171
- 0
docs/二次开发/工程新建保存加载与JSON说明.md Zobrazit soubor

@@ -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`

+ 2
- 1
docs/代码功能阅读清单.md Zobrazit soubor

@@ -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`

### 重点调用链



Načítá se…
Zrušit
Uložit