Просмотр исходного кода

chore: 更新项目文档与构建脚本

main
suyu 3 недель назад
Родитель
Сommit
d882034c46
11 измененных файлов: 463 добавлений и 70 удалений
  1. +4
    -1
      .gitignore
  2. +3
    -2
      AGENTS.md
  3. +46
    -25
      docs/ai/handoff.md
  4. +9
    -5
      docs/architecture.md
  5. +16
    -10
      docs/工程格式说明.md
  6. +6
    -4
      docs/测试约定.md
  7. +26
    -23
      docs/用户使用/数量边界确认方案.md
  8. +56
    -0
      scripts/build_and_run_qt.ps1
  9. +174
    -0
      scripts/measure_cpp_code.ps1
  10. +122
    -0
      scripts/package_qt_app.ps1
  11. +1
    -0
      scripts/run_qt_tests.ps1

+ 4
- 1
.gitignore Просмотреть файл

@@ -57,12 +57,15 @@ Thumbs.db
Desktop.ini

/scripts/*
!/scripts/build_and_run_qt.ps1
!/scripts/measure_cpp_code.ps1
!/scripts/package_qt_app.ps1
!/scripts/run_qt_tests.ps1
/docs/images
/docs/类图Mermaid代码.md
/other
设计方案书.doc
/学习
/json
/代码
/other_version
/app_copy

+ 3
- 2
AGENTS.md Просмотреть файл

@@ -42,8 +42,9 @@
- Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File`
- 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition`
- 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/`
- Debug 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`
- Release 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release`
- 项目编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`
- 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release`
- Qt 项目打包:`pwsh -NoLogo -NoProfile -File .\scripts\package_qt_app.ps1`
- 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'`

## 代码与架构约束


+ 46
- 25
docs/ai/handoff.md Просмотреть файл

@@ -1,47 +1,68 @@
# 当前开发交接

> 更新日期:2026-08-24。本文件只保留当前工作区状态、最近验证和下一步,不记录历史开发流水账;稳定架构和业务细节分别以 `docs/architecture.md` 及对应专题文档为准。
> 更新日期:2026-08-25。本文件只保留当前工作区状态、最近验证和下一步,不记录历史开发流水账;稳定架构和业务细节分别以 `docs/architecture.md` 及对应专题文档为准。

## 当前状态

- 分支:`main`
- 核心闭环仍可用:HMI 编辑、结构化梯形图、离线仿真、工程 JSON、运行监控、自由监控和 Modbus RTU 真机读写均已实现
- 当前梯形图条件只包含 M 常开/常闭触点、M 上升沿/下降沿触点和 D 与常量比较;输出只包含普通/置位/复位 M 线圈、MOVE、ADD 和 SUB
- T/C 触点、TON、CTU、CTD 已从领域模型、JSON、编辑服务、离线执行器、属性面板、工具栏、图标和自动化测试中整条移除,不是仅隐藏界面按钮
- 工程格式仍为严格的 `1.0`;包含 `timerContact`、`counterContact`、`ton` 或 `counter` 节点的工程现在会因节点类型不受支持而加载失败,不做自动迁移
- 当前没有代码阻塞,下一步是完成人工界面检查并整理提交
- 核心闭环可用:HMI 编辑、结构化梯形图、离线仿真、工程 JSON、运行监控、自由监控和 Modbus RTU 真机读写均已实现
- 梯形图条件区已经统一为显式模型:可见横线保存为 `Wire`,明确断路保存为 `Gap`,输出固定在第 11 列
- 工程 JSON 继续严格使用 `1.0`,不增加版本迁移或早期同版本草稿兼容分支
- HMI 进度条控件已经完整移除,编辑器、运行投影和工程 JSON 均不再支持该类型
- 返回编辑态后会拒绝退出前排队的离线扫描轨迹,梯形图不会重新显示旧的绿色导通状态
- 新建工程名称为空或全是空白时会禁用确认按钮,取消输入时继续安全保留当前工程,不再静默接受无效名称
- 第一次保存和工程另存为会用当前工程名称预填 `.json` 文件名,并兼容 Windows 非法字符与保留设备名
- PLC 通信配置支持刷新当前可用串口及按当前站号自动搜索:搜索异步遍历可用串口和全部受支持串口参数,找到后自动回填并进入正式连接,搜索可随时取消
- HMI/梯形图复制粘贴、数量边界收紧和全局对话框标题栏调整仍保留在当前未提交工作区
- 当前没有代码阻塞,Release 功能测试、性能测试和主程序构建均已通过

## 工作区状态

- 删除定时器/计数器能力的 21 个 `app` 源码和测试文件仍处于未提交修改状态,属于当前任务成果,不能回退
- `app/src/services/offline_simulation_service.cpp` 只修正了“重启会话时清空 T/C 状态”的过时注释,没有行为改动
- `docs/用户使用/软件使用帮助.md` 处于已删除状态,本轮按现有工作区保留删除,不恢复旧内容
- 本轮已同步入口文档、工程格式、数量边界、开发顺序、测试约定和 XDH 指令摘要;各文档只保留自身负责的信息
- 当前未提交功能改动包含 HMI/梯形图复制粘贴、数量边界收紧、对话框标题栏调整、显式 `Wire / Gap` 梯形图连接模型、离线轨迹退出时序修复、HMI 进度条控件移除,以及 PLC 自动搜索
- PLC 自动搜索新增服务层搜索契约、独立 Qt Modbus RTU 探测实现、配置对话框进度/取消交互、主窗口依赖注入、候选覆盖测试和离屏对话框流程测试
- Git 忽略的 `json/` 本地示例工程已同步当前格式;综合测试工程删除了进度条对象和关联说明,电机正反转工程无需内容调整
- `.gitignore`、`AGENTS.md`、未跟踪脚本和测试大纲文档属于工作区已有内容,没有被本轮回退
- `build/` 只保存本地构建和测试输出,不纳入 Git

## 本轮变更结论

- `LogicNodeConfig` 不再包含 Timer/Counter 地址、触点或输出配置,运行前也不再做 T/C 资源引用检查
- `SoftwareLogicExecutor` 只保留 M/D 读写、边沿触点跨扫描状态、线圈和字操作执行;轨迹不再保存定时器或计数器状态
- JSON 读写只接受 `contact`、`edgeContact`、`coil`、`compare`、`move` 和 `arithmetic`
- 梯形图工具栏、指令对话框、属性面板、绘制和图标工厂均不再提供 T/C、TON、CTU、CTD 入口
- 自动化测试已删除对应领域、JSON、编辑和离线执行断言,同时保留当前指令、结构化梯形图、运行模式和 PLC 轮询回归
- 条件区每一段可见、可选择和可删除横线都持久化为 `Wire(columnSpan)`;没有横线的位置使用 `Gap(columnSpan)` 明确占位
- 有输出的网络必须由 `Node / Wire / Gap` 显式占满前 10 列;直接添加输出生成 `Wire(10) + Output`,一个触点后添加输出生成 `Node + Wire(9) + Output`
- 删除触点会在原位置留下单格 `Gap`;删除横线只断开选中的网格,支持在 `Gap` 上补横线或原位插入触点
- 并联各支路必须显式等宽,短支路补线保存为真实 `Wire`;母线、节点和输出自身端子、并联竖线仍由结构派生
- `Gap` 可以保存草稿,但运行校验会拒绝;执行器不再把 `condition=null + output` 解释为隐式恒真网络
- JSON 继续严格读写 `formatVersion: "1.0"`,新增 `kind: "gap"` 表达式;早期依赖隐式输出连线或隐式并联补线的同版本草稿不会自动迁移
- `RuntimePanelController` 只在仍处于离线运行且执行器状态为 `Running` 时接收扫描轨迹;返回编辑态后的过期排队回调会被丢弃
- HMI 控件枚举、注册描述、专用配置模型、编辑服务比较/默认值、JSON 字段、画布绘制、属性表单、添加动作和工具栏图标中的进度条代码均已删除
- 严格 `1.0` 工程只接受当前注册表中的 7 类 HMI 控件;包含已移除控件类型的工程会按不支持类型拒绝加载,不做同版本迁移
- HMI 编辑和工程管理测试样例已移除该控件,并保留未知 HMI 类型加载失败的通用回归覆盖
- 自动搜索固定使用界面当前站号,按优先级遍历每个可用 PC 串口的 60 组受支持串口参数;不穷举 `1~247` 站号
- 每组参数只读 `D0`,不写 PLC;正常回复或合法 Modbus 异常回复都视为参数匹配,找到后先释放搜索串口再交给原正式连接流程
- 搜索期间配置表单和确认按钮禁用,自动搜索按钮切换为停止命令;取消、无串口和全部失败都有明确结果,找到后自动回填端口、波特率、数据位、校验位和停止位
- 端口下拉框右侧提供刷新按钮;窗口打开和手动刷新时都只保留系统当前实际存在的串口,不再把已经拔掉的上次配置端口重新加入列表

## 最近验证

- 2026-08-24 重新构建并通过 9 个 Release 功能测试目标:Domain、Service、JSON 和 Fake PLC 全部通过
- 独立 Release 性能目标通过:100 个控制逻辑扫描约 `0.123 ms`,4001 个 M 寄存器完整读写约 `0.048 ms`;CSV 位于 `build/tests/release/performance_tests/benchmark.csv`
- Qt Release 主程序已构建并链接成功:`build/release-docs/release/integrated_platform.exe`
- 本轮未连接真实 PLC:改动没有触及 Modbus 通信、真机运行模式或地址映射;继续沿用已有 COM3 安全读写和 RUN 联动验收结果
- 2026-08-25 PLC 自动搜索完成后,11 个 Release 功能测试目标全部通过;`plc_connection_dialog_tests` 覆盖开始、取消、进度、参数回填和端口刷新,`plc_runtime_tests` 覆盖 180 组候选优先级、去重和边界
- 2026-08-24 修复新建工程空名称静默失败后,10 个 Release 功能测试目标全部通过,Release 主程序重新编译并链接成功
- 2026-08-24 删除 HMI 进度条后重新 qmake、构建并通过 10 个 Release 功能测试目标:Domain、Alarm、HMI 编辑、梯形图编辑、离线仿真、工程管理、寄存器监控、运行模式、运行面板控制器和 PLC Runtime 全部通过
- 新增真实 `RuntimePanelController + LogicEditorWidget` 无界面回归测试,确认退出前排队的 `scanCompleted` 在编辑态不会恢复绿色轨迹
- 独立 Release 性能目标通过:100 个控制逻辑扫描约 `0.207 ms`,4001 个 M 寄存器完整读写约 `0.048 ms`;CSV 位于 `build/tests/release/performance_tests/benchmark.csv`
- Qt Release 主程序重新构建并链接成功:`build/release/release/integrated_platform.exe`
- 工程管理测试覆盖 `Gap` 严格 `1.0` 往返、`Wire(10)` 恒真输出、拒绝加载 `condition=null + output` 的隐式连线草稿,以及拒绝未知 HMI 控件类型
- `json/full_hmi_ladder_test.json` 和 `json/motor_forward_reverse.json` 均已使用当前 `JsonProjectStorage` 实际加载成功,并通过领域校验
- 2026-08-25 使用新搜索器按站号 `1` 对本机 `COM1/COM2/COM3` 完成 180 组参数的只读 `D0` 尝试,设备未返回 Modbus 响应并正确报告未找到;本次没有写寄存器,不需要恢复原值,也不能作为成功联机证据

## 待完成

1. 人工打开主界面,确认梯形图工具栏、属性面板和指令对话框不再出现 T/C、TON、CTU、CTD,当前保留指令仍能正常添加和编辑
2. 如需兼容历史 `1.0` 工程中的定时器/计数器节点,单独确定格式升级或迁移方案;当前实现按不兼容并拒绝加载处理
3. 提交前再次确认 `git status --short` 和提交范围,将代码删除与配套文档作为一个逻辑完整改动提交
1. 人工确认 HMI 工具栏和属性面板不再出现进度条入口或空白属性行,再操作直接输出、触点后输出、横线选中、单格断线、断路补线、断路插入触点、删除触点和保存重载,并确认离线仿真返回编辑态后不保留绿色轨迹
2. 需要继续使用的早期隐式连线 `1.0` 草稿应在当前编辑器中重新保存为显式 `Wire / Gap` 结构,本项目不提供自动迁移
3. 在确认 PLC 已上电、RS-485 接线正确、COM2 已配置为 Modbus RTU 从站且站号正确后,人工验收自动搜索能找到配置并自动完成正式连接
4. 提交时区分本轮显式连接模型、PLC 自动搜索与工作区其他已有改动,避免混入无关未跟踪文件

## 下一会话起点

1. 先运行 `git status --short`,保留当前源码、测试、文档修改和使用帮助删除
2. 涉及梯形图能力时以 `LogicNodeConfig` 当前六类配置为准;Qt 的 `QTimer` 只是轮询或界面刷新机制,不代表梯形图定时器指令
3. 修改代码后运行受影响测试、独立性能目标、Release 构建和 `git diff --check`;涉及真机通信时再按 `AGENTS.md` 做 COM3 读写及恢复验证
1. 先运行 `git status --short`,保留工作区全部现有修改,不回退来源不明的文件
2. PLC 设备条件就绪后先在配置窗口保持正确站号,点击自动搜索,确认进度、停止、未找到提示,以及找到后参数回填和正式连接
3. 人工验收 HMI 时确认“更多控件”菜单只保留页面跳转、报警列表和报警配置;验收梯形图时重点确认选中有线网格仍显示横线,删除后只有目标格变为断路,补线后恢复运行资格
4. 修改代码后运行 `pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release -Suite All`、Release 主程序构建和 `git diff --check`

+ 9
- 5
docs/architecture.md Просмотреть файл

@@ -70,7 +70,7 @@ Project
- 需要寄存器的 HMI 控件、报警条件和梯形图节点直接保存 M/D 地址;项目不再维护统一 `dataPoints` 表
- `registerComments` 只是 M/D 地址元数据,不保存当前值或引用位置,也不会让地址自动进入 PLC 轮询

工程 JSON 格式固定为严格的 `1.0`。加载顺序为 JSON 解析、版本检查、字段解析、领域校验,全部成功后才能替换当前工程;保存使用原子提交。当前版本不读取旧 `dataPoints`、`stages/branches` 或缺少必填字段的同版本草稿。字段定义见 `docs/工程格式说明.md`。
工程 JSON 格式固定为严格的 `1.0`。加载顺序为 JSON 解析、版本检查、字段解析、领域校验,全部成功后才能替换当前工程;保存使用原子提交。当前版本不读取旧 `dataPoints`、`stages/branches`,也不迁移缺少显式横线、断路或其他必填结构的早期同版本草稿。字段定义见 `docs/工程格式说明.md`。

校验分两级:

@@ -87,22 +87,24 @@ Project
ConditionExpression
├── Node 触点、边沿或比较条件
├── Wire 无地址横线,保存条件区列跨度
├── Gap 明确断开的空白网格,保存条件区列跨度
├── Series 子表达式执行 AND
└── Parallel 子表达式执行 OR
```

输出是网络最右侧的单独节点。`LogicEditorService` 把串联、并联、旁路、替换和删除转换为表达式树操作,失败时回退整个编辑;`LogicEditorWidget` 只把树投影成正交网格。竖线由 `Parallel` 边界派生,网格坐标和自由线段不持久化,因此领域模型不会产生悬空线、环路或多输出路径。

单网络前 10 列为条件区,输出最多位于第 11 列;横线只能占条件区。第 11 个条件、过深表达式或过多并联行会在编辑和加载阶段被拒绝,具体数值以数量边界文档为准。
单网络前 10 列为条件区,第 11 列固定为输出位置;存在输出时,前 10 列必须由 `Node / Wire / Gap` 显式占满。横线和断路只能占条件区。第 11 个条件、未占满或超过 10 列的输出网络、过深表达式或过多并联行会在编辑和加载阶段被拒绝,具体数值以数量边界文档为准。

控制逻辑组可以暂时没有网络。新建工程和新建控制逻辑都保持 `rungs=[]`,用户第一次添加条件、横线或输出时,编辑服务在同一个原子操作中创建网络;删除最后一个网络后也允许回到空逻辑。画布中的空网格只表示可编辑位置,不绘制真实横线;模型中的 `Wire`、实际节点、输出连接,以及并联短支路到右侧汇合点的必要结构连接,才投影为梯形图导线。并联短支路的结构连接由 UI 从 `Parallel` 宽度派生,不额外写入 `Wire`。连续追加单格横线占满十个条件列后,下一次无选中目标的追加会在同一撤销记录中创建下一个网络。
控制逻辑组可以暂时没有网络。新建工程和新建控制逻辑都保持 `rungs=[]`,用户第一次添加条件、横线或输出时,编辑服务在同一个原子操作中创建网络;删除最后一个网络后也允许回到空逻辑。条件区中每一段可见、可选择和可删除的横线都必须是持久化 `Wire`;没有横线的位置由 `Gap(columnSpan)` 明确占位。直接添加输出会创建 `Wire(10) + Output`,一个触点后添加输出会创建 `Node + Wire(9) + Output`。删除触点或横线只把对应网格替换为 `Gap`,不会让左右两侧自动重新接通;在该网格重新添加横线或触点后才恢复连接。并联各支路必须用真实 `Wire` 或 `Gap` 补到相同列宽。左右母线、节点和输出图元自身的端子线、并联竖线仍由结构派生,不保存为自由像素线。连续追加单格横线占满十个条件列后,下一次无选中目标的追加会在同一撤销记录中创建下一个网络。

网络运行语义:

- 空网络不执行
- 有条件但没有输出的已启用网络不能运行
- 有输出但没有条件的网络是恒真网络,每次扫描执行输出
- 直接接通的输出使用 `Wire(10)` 显式表示恒真路径,每次扫描执行输出
- `Wire` 的逻辑值为真,但运行轨迹是否导通仍取决于上游电流
- `Gap` 表示明确断路,可以保存编辑草稿,但任何包含 `Gap` 的启用网络都不能进入运行态

节点配置使用 `std::variant` 的独立类型。当前条件包括 M 常开/常闭触点、M 上升沿/下降沿触点和 D 与常量比较;输出包括普通/置位/复位 M 线圈、MOVE、ADD 和 SUB。新增或恢复指令必须增加独立配置、校验、JSON、执行、编辑和测试分支,不能向通用节点堆叠无关字段。

@@ -139,7 +141,9 @@ PLC <-> PlcCommunicationService <--------------------┘

`PlcCommunicationService` 使用 `QModbusRtuSerialMaster` 异步连接、轮询和单点写入,UI 线程不等待串口响应。Modbus 地址使用从 `0` 开始的原始地址。

轮询集合由 HMI、报警、梯形图中的显式 M/D 引用和自由监控地址合并,按区域去重并合并相邻地址。横线和注释不进入轮询。动态修改监控地址时,当前请求按旧快照解析,空闲后再应用新集合。
PLC 配置窗口的自动搜索使用独立 `PlcDiscoveryService`,不复用正式轮询主站,避免搜索失败污染连接状态、缓存和首读资格。搜索固定使用界面当前站号,优先当前串口参数,再异步遍历全部可用 PC 串口和项目允许的 60 组波特率、数据位、校验位及停止位组合;每组参数只读 `D0`,正常响应或合法 Modbus 异常响应都能证明端口、站号和串口帧匹配。找到后必须先释放搜索串口,再把参数交给原有正式连接流程;取消或全部失败时不保留搜索连接。

轮询集合由 HMI、报警、梯形图中的显式 M/D 引用和自由监控地址合并,按区域去重并合并相邻地址。横线、断路和注释不进入轮询。动态修改监控地址时,当前请求按旧快照解析,空闲后再应用新集合。

PLC 缓存只保存最后一次成功读回值。写请求受理后不乐观修改缓存,界面等待后续轮询确认真实值。真机运行资格属于当前连接代次:必须完成完整首读,旧连接的异步回复不能污染重连后的状态。



+ 16
- 10
docs/工程格式说明.md Просмотреть файл

@@ -61,7 +61,8 @@
| `label` | 必须为 `null` | 无 |
| `pageJump` | 必须为 `null` | `targetPageId` |
| `alarmList` | 必须为 `null` | 无 |
| `progressBar` | D 地址或 `null` 草稿 | `minimumValue`、`maximumValue`、`showValue` |

`type` 只接受表中列出的控件类型。其他类型按严格 `1.0` 规则直接拒绝加载,不做兼容迁移。

按钮操作字符串为 `setOn`、`setOff`、`toggle` 或 `momentaryOn`。PageJump 使用目标页面稳定 ID,不使用名称或数组位置:

@@ -81,11 +82,6 @@

AlarmList 只负责显示项目级当前报警,不保存独立触发逻辑,也不直接绑定单个寄存器。编辑界面的标题输入上限为 12 个字符。多个页面放置 AlarmList 时共享同一组当前报警和确认状态。运行态没有报警时控件隐藏;有报警时按当前页的实际记录数收缩高度,每页最多显示 5 条。超过 5 条时通过表头翻页按钮查看,记录不会被丢弃。标题和报警文本按单行绘制,空间不足时显示省略号。报警恢复后对应记录立即移除,最后一条报警恢复后控件再次隐藏。

ProgressBar 使用绑定 D 地址的有符号 16 位字值,并将其限制在 `minimumValue` 到
`maximumValue` 的范围后映射为百分比。`minimumValue` 和 `maximumValue` 必须是有符号 16 位整数,
且最小值严格小于最大值;`showValue` 为布尔值,决定是否在进度条上显示百分比。ProgressBar
是只读控件,不产生 PLC 写请求。

## 报警定义

```json
@@ -144,15 +140,23 @@ ProgressBar 使用绑定 D 地址的有符号 16 位字值,并将其限制在

`enabled` 决定离线执行器是否扫描该逻辑。所有启用逻辑按 `controlLogics` 数组顺序执行;禁用逻辑仍要求 ID、名称、网络和表达式结构合法,但允许保留未完成节点作为草稿。

控制逻辑初始可以没有网络,`rungs` 为空不是错误。编辑器第一次添加条件、横线或输出时才创建网络;删除最后一个网络后可以再次保存为空逻辑。空网格属于界面编辑辅助,不写入 `Wire`,也不绘制真实梯形图导线
控制逻辑初始可以没有网络,`rungs` 为空不是错误。编辑器第一次添加条件、横线或输出时才创建网络;删除最后一个网络后可以再次保存为空逻辑。没有输出的空网络草稿允许 `condition` 和 `output` 同时为 `null`。存在输出时,`condition` 不能为空,并且必须显式占满前 10 列

网络、`Node / Wire / Series / Parallel` 条件表达式、M 触点、M 边沿、D 比较、M 线圈和字操作指令字段沿用当前结构化梯形图模型。横线保存为恒真的 `Wire` 叶子:
网络、`Node / Wire / Gap / Series / Parallel` 条件表达式、M 触点、M 边沿、D 比较、M 线圈和字操作指令字段沿用当前结构化梯形图模型。条件区中每一段可见、可编辑横线保存为恒真的 `Wire` 叶子:

```json
{"id": "wire-1", "kind": "wire", "columnSpan": 2}
```

`columnSpan` 是必填整数,范围为 `1~10`。它表示横线占用的条件区逻辑网格列数,不是像素长度。单个网络前 10 列是条件区,第 11 列固定放输出指令。竖线不保存为独立对象,而是由 `Parallel` 表达式的支路边界派生。画布像素坐标、自由连线和运行轨迹是 UI 或运行会话投影,不进入 JSON;横线不绑定寄存器,也不产生 M/D 轮询地址。节点配置的类型和值域如下:
明确断开的连续空白网格保存为 `Gap` 叶子:

```json
{"id": "gap-1", "kind": "gap", "columnSpan": 1}
```

`Wire` 和 `Gap` 的 `columnSpan` 都是必填整数,范围为 `1~10`,表示占用的条件区逻辑网格列数,不是像素长度。删除一格横线或触点时,该格改为 `Gap(1)`;多格横线只断开被删除的格,其余部分仍是 `Wire`。`Gap` 可以保存和重新加载,但运行校验必须拒绝包含 `Gap` 的启用网络。

单个网络前 10 列是条件区,第 11 列固定放输出指令。存在输出时,前 10 列必须由 `Node / Wire / Gap` 精确占满;直接添加输出保存为 `Wire(10) + Output`,一个触点后添加输出保存为 `Node + Wire(9) + Output`。并联支路必须显式等宽,短支路的补线也保存为真实 `Wire`。左右母线、节点和输出自身端子、并联竖线不保存为独立对象,而是由表达式结构派生。画布像素坐标、自由连线和运行轨迹是 UI 或运行会话投影,不进入 JSON;横线和断路不绑定寄存器,也不产生 M/D 轮询地址。节点配置的类型和值域如下:

```json
{"type": "contact", "address": {"area": "M", "index": 0}, "mode": "normallyOpen"}
@@ -172,4 +176,6 @@ MOVE、ADD 和 SUB 只能把结果写入 D 地址,源操作数和算术左右

保存顺序为领域校验、JSON 序列化、`QSaveFile` 原子提交。加载顺序为 JSON 语法、`formatVersion`、必填字段和类型、领域关系校验,全部成功后才替换当前工程。

工程保存允许未绑定的寄存器控件和未完成梯形图草稿。进入离线运行还必须通过运行校验:所有 HMI 寄存器控件完成绑定、所有 PageJump 目标有效、所有报警定义合法、所有启用逻辑包含输出指令;禁用草稿逻辑不阻止运行。只有输出而没有条件的网络表示左侧电源线直接接通,运行时按恒真条件执行。
工程保存允许未绑定的寄存器控件和未完成梯形图草稿。进入离线运行还必须通过运行校验:所有 HMI 寄存器控件完成绑定、所有 PageJump 目标有效、所有报警定义合法、所有启用逻辑包含输出指令,并且条件区中不存在 `Gap`;禁用草稿逻辑不阻止运行。左侧电源线直接接通的输出必须保存为 `Wire(10) + Output`,不能省略 `condition`。

当前实现只读写本文定义的严格 `1.0`。早期同为 `1.0`、但依赖隐式输出连线或隐式并联补线的草稿不会自动迁移,结构不符合当前领域校验时直接加载失败。

+ 6
- 4
docs/测试约定.md Просмотреть файл

@@ -6,11 +6,11 @@

| 类型 | 证明内容 | 执行方式 |
| --- | --- | --- |
| 功能自动化测试 | 领域规则、服务用例、JSON 工程、离线执行和 Fake PLC 状态机正确 | 提交前自动执行 |
| 功能自动化测试 | 领域规则、服务用例、JSON 工程、离线执行、运行面板时序和 Fake PLC 状态机正确 | 提交前自动执行 |
| 性能测试 | 代表性工程扫描和边界规模寄存器访问性能 | 独立执行 |
| 真实 PLC 实测 | 串口、Modbus RTU、PLC 响应、接线和现场联动有效 | 人工执行并记录 |

保留 Qt UI 自动化测试。界面是否可操作、布局是否正常和完整工作流由交付前人工验收确认;领域规则和业务结果由更稳定的 Domain、Service、JSON 和 Fake PLC 测试负责
使用自动化测试验证界面布局、视觉效果和真实鼠标操作,这些内容由交付前人工验收确认。跨运行模式的 Qt 排队信号无法只靠服务测试证明,因此保留一个无界面运行的控制器测试,专门验证退出离线仿真后不会恢复旧梯形图轨迹;PLC 自动搜索还保留一个离屏对话框流程测试,验证开始、取消、进度和找到后自动提交参数

## 功能自动化测试

@@ -24,6 +24,8 @@
| `project_management_tests` | JSON 往返、非法文件和工程保存状态 |
| `register_monitor_service_tests` | 监视地址、读写和活动仓库 |
| `runtime_mode_service_tests` | 编辑、离线和真机状态转换 |
| `runtime_panel_controller_tests` | 离线扫描排队回调和编辑态轨迹清理 |
| `plc_connection_dialog_tests` | PLC 端口刷新及自动搜索开始、取消、进度和参数回填 |
| `plc_runtime_tests` | PLC 缓存、轮询、错误恢复和 Fake gateway |

同一规则只在成本最低、失败定位最清楚的层验证:
@@ -41,7 +43,7 @@
pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release
```

`app/tests/tests.pro` 只聚合上述 9 个功能测试目标。测试构建输出放在 `build/`,不写入 `app/`。
`app/tests/tests.pro` 只聚合上述 11 个功能测试目标。测试构建输出放在 `build/`,不写入 `app/`。

## 性能测试

@@ -85,7 +87,7 @@ pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release

## 提交前检查

1. 运行 9 个 Release 功能测试目标
1. 运行 11 个 Release 功能测试目标
2. 单独运行 `performance_tests` 并保留基准输出
3. 构建 Qt Release 主程序
4. 按风险决定是否重新执行真实 PLC 实测;未重测时引用已有验收记录并说明原因


+ 26
- 23
docs/用户使用/数量边界确认方案.md Просмотреть файл

@@ -10,36 +10,39 @@
| PLC 软元件 | D 地址 | 0 | 4000 | 拒绝保存、加载或连接使用 | 项目需求已经明确限定 |
| 数值 | D 字、比较值、阈值、普通常量 | -32768 | 32767 | 编辑和加载时拒绝越界值,运算溢出时压到边界值 | D 是有符号 16 位数据 |
| 工程文件 | 单个 JSON 文件大小 | 0 | 16 MiB | 读取 JSON 前直接拒绝 | 防止超大文件一次性占满内存 |
| HMI | 页面数量 | 0 | 128 | 第 129 个页面不能创建或加载 | 控制工程树和运行页面的内存开销 |
| HMI | 单页控件数量 | 0 | 512 | 第 513 个控件不能创建或加载 | 保证编辑画布和运行刷新可控 |
| HMI | 页面数量 | 0 | 32 | 第 33 个页面不能创建或加载 | 控制工程树和运行页面的内存开销 |
| HMI | 单页控件数量 | 0 | 128 | 第 129 个控件不能创建或加载 | 保证编辑画布和运行刷新可控 |
| HMI | 全工程控件数量 | 0 | 2048 | 第 2049 个控件不能创建或加载 | 限制工程快照、JSON 和跨页面总刷新工作量 |
| HMI | 页面宽度 | 320 | 1600 | 拒绝保存、加载或编辑 | 当前工具只面向本机 HMI 编辑,限制在可合理查看的桌面页面范围内 |
| HMI | 页面高度 | 200 | 800 | 拒绝保存、加载或编辑 | 当前工具只面向本机 HMI 编辑,限制在可合理查看的桌面页面范围内 |
| HMI | 控件宽度、高度 | 1 | 8192 | 拒绝保存或加载;控件还必须完整放在页面内 | 防止异常尺寸和越界绘制 |
| HMI | 单控件扩展属性 | 0 对 | 64 对 | 第 65 对属性不能加载或保存 | 防止属性对象无限增长 |
| HMI | 字号 | 6 | 72 | 拒绝保存、加载或编辑 | 控制文字可读性和控件布局开销 |
| HMI | 控件属性文本输入长度 | 0 字符 | 12 个字符 | 编辑界面限制继续输入 | 保持 HMI 控件属性文本紧凑,避免编辑和运行显示溢出 |
| 报警 | 报警定义数量 | 0 | 512 | 第 513 条报警不能创建或加载 | 报警每个刷新周期都要判断 |
| 报警 | 报警定义数量 | 0 | 256 | 第 257 条报警不能创建或加载 | 报警每个刷新周期都要判断 |
| 报警 | 报警列表标题输入长度 | 0 字符 | 12 个字符 | 编辑界面限制继续输入 | 标题需要在表头为翻页控件保留空间,显示不下时使用省略号 |
| 报警 | 单条报警文本输入长度 | 1 字符 | 20 个字符 | 编辑界面限制继续输入 | 报警信息按单行显示并在宽度不足时使用省略号 |
| 报警 | 单页同时可见行数 | 1 行 | 5 行 | 超出后通过表头按钮翻页,活动记录不丢失 | 控制默认控件高度并避免少量报警产生大块留白 |
| 注释 | M/D 软元件注释数量 | 0 | 8002 | 超出后拒绝保存或加载 | M0~M4000 和 D0~D4000 每个地址最多一条,共 8002 条 |
| 控制逻辑 | 控制逻辑数量 | 0 | 128 | 第 129 组不能创建或加载 | 控制工程树和扫描工作量 |
| 控制逻辑 | 每组逻辑的网络数量 | 0 | 1024 | 第 1025 个网络不能创建或加载 | 每次软件扫描都要执行这些网络 |
| 单个网络 | 条件表达式节点和叶子总数 | 0 | 4096 | 超出后编辑操作回退,JSON 加载失败 | 防止单个网络过度复杂 |
| 单个网络 | 表达式嵌套深度 | 1 层 | 20 层 | 第 21 层在解析时直接拒绝 | 10 列条件区下,第 20 层是规范化串并联结构能够达到的最深边界 |
| 单个容器 | 串联或并联子项数量 | 2 | 256 | 第 257 项不能加入或加载 | 控制单层表达式规模 |
| 单个网络 | 条件区列数 | 0 | 10 | 第 11 个条件不能加入,加载失败 | 0 列表示无条件输出;最多 10 列保证网络容易查看 |
| 单个网络 | 总列数 | 1(无条件线圈) | 11 | 第 11 列固定放输出指令 | 普通网络前 10 列放条件;无条件线圈可以直接从左侧电源线接通 |
| 单个网络 | 逻辑网格行数 | 1 | 256 | 超出后编辑操作回退,加载失败 | 防止并联支路纵向无限扩展 |
| 控制逻辑 | 控制逻辑数量 | 0 | 32 | 第 33 组不能创建或加载 | 控制工程树和扫描工作量 |
| 控制逻辑 | 每组逻辑的网络数量 | 0 | 256 | 第 257 个网络不能创建或加载 | 每次软件扫描都要执行这些网络 |
| 控制逻辑 | 全工程网络数量 | 0 | 2048 | 第 2049 个网络不能创建或加载 | 限制离线扫描、编辑快照和工程文件总工作量 |
| 单个网络 | 条件表达式节点和叶子总数 | 0 | 1024 | 超出后编辑操作回退,JSON 加载失败 | 防止单个网络过度复杂 |
| 单个网络 | 表达式嵌套深度 | 1 层 | 12 层 | 第 13 层在解析时直接拒绝 | 控制递归求值和画布布局深度 |
| 单个容器 | 串联或并联子项数量 | 2 | 64 | 第 65 项不能加入或加载 | 控制单层表达式规模 |
| 单个网络 | 条件区列数 | 0(空网络草稿) | 10 | 第 11 个条件不能加入,加载失败;存在输出时不足 10 列也失败 | 输出网络前 10 列必须由条件、横线或断路显式占满 |
| 单个网络 | 总列数 | 0(空网络草稿) | 11 | 第 11 列固定放输出指令 | 运行网络固定为前 10 列条件区加第 11 列输出 |
| 单个网络 | 逻辑网格行数 | 1 | 64 | 超出后编辑操作回退,加载失败 | 防止并联支路纵向无限扩展 |
| 横线 | 单段横线跨度 | 1 列 | 10 列 | 拒绝编辑或加载 | 横线只能占用条件区,不能占用输出列 |
| 断路 | 单段空白跨度 | 1 列 | 10 列 | 拒绝编辑或加载 | `Gap` 明确占用条件区空白网格,可保存但不能运行 |
| 字符串 | ID、格式版本、页面跳转目标 ID | 0 字节 | 128 个 UTF-8 字节 | 拒绝保存或加载;业务要求非空的 ID 仍必须非空 | ID 只用于稳定识别,不需要长文本 |
| 字符串 | 工程名、页面名、控件文本、报警文本、网络名称 | 0 字节 | 4096 个 UTF-8 字节 | 拒绝保存或加载;业务要求非空的字段仍必须非空 | 防止异常工程数据无限增长;HMI 控件文本、报警标题和报警文本编辑界面分别使用上方的 12/12/20 字符输入限制 |
| 字符串 | 工程名、页面名、控件文本、报警文本、网络名称 | 0 字节 | 256 个 UTF-8 字节 | 拒绝保存或加载;业务要求非空的字段仍必须非空 | 防止名称和显示文本异常增长;HMI 控件文本、报警标题和报警文本编辑界面分别使用上方的 12/12/20 字符输入限制 |
| 注释 | 软元件注释 | 1 字节 | 64 个 UTF-8 字节 | 拒绝编辑、保存或加载;禁止回车和换行 | 注释绘制在梯形图节点下方,必须保持紧凑单行 |
| 注释 | 梯形图网络注释 | 0 字节 | 128 个 UTF-8 字节 | 拒绝编辑、保存或加载;禁止回车和换行 | 注释绘制在网络标题下方,换行会挤占梯形图区域 |
| 字符串 | HMI 属性名称 | 1 字节 | 128 个 UTF-8 字节 | 拒绝保存或加载 | 属性名是短标识 |
| 字符串 | HMI 属性值 | 0 字节 | 4096 个 UTF-8 字节 | 拒绝保存或加载 | 属性值可能保存显示配置,但不能无限增长 |
| 字符串 | HMI 属性值 | 0 字节 | 1024 个 UTF-8 字节 | 拒绝保存或加载 | 属性值可能保存显示配置,但不能无限增长 |
| 自由监控 | 去重后的监控地址 | 0 | 64 | 第 65 个监控地址不能加入 | 这是用户临时监控区,不应拖慢 PLC 轮询 |
| 编辑历史 | 可撤销记录 | 0 | 100 | 新操作加入后自动删除最旧记录 | 历史记录会保存工程快照,占用内存 |
| 编辑历史 | 可撤销记录 | 0 | 30 | 新操作加入后自动删除最旧记录 | 历史记录会保存工程快照,占用内存 |
| 输出面板 | 日志条数 | 0 | 1000 | 加新日志前自动删除最旧一条 | 日志只用于查看近期状态 |
| PLC 通信 | 站号 | 1 | 247 | 不允许发起连接 | 248~254 虽然信捷手册可表示,但属于 Modbus 保留地址,本项目不使用 |
| PLC 通信 | 波特率 | 9600 | 115200 | 只接受 9600、19200、38400、57600、115200 | 只开放本项目已经验证的串口档位 |
@@ -49,28 +52,28 @@
| PLC 通信 | 失败重试次数 | 0 | 5 | 不允许发起连接 | 采用信捷手册给出的 Modbus 重试范围 |
| PLC 通信 | 轮询周期 | 50 ms | 10000 ms | 不允许发起连接 | 太快会压满串口,太慢会让画面失去实时性 |
| PLC 通信 | 单个 Modbus 读块 | 1 个 | 120 个 | 自动拆成多个读块 | 信捷 RTU 数据区最多 252 字节,120 个字安全留有余量 |
| PLC 通信 | 去重后的总轮询地址 | 0 | 1024 | 拒绝连接或拒绝更新轮询表 | 限制每轮通信总工作量 |
| PLC 通信 | 总轮询读块 | 2(无配置时保底读取 M0、D0) | 64 | 拒绝更新轮询表 | 地址太分散时,即使总数不大也会产生大量报文 |
| PLC 通信 | 去重后的总轮询地址 | 0 | 256 | 拒绝连接或拒绝更新轮询表 | 限制每轮通信总工作量 |
| PLC 通信 | 总轮询读块 | 2(无配置时保底读取 M0、D0) | 8 | 拒绝更新轮询表 | 地址太分散时,即使总数不大也会产生大量报文 |
| PLC 写入 | 同时未完成的写请求 | 0 | 1 | 前一笔没完成时拒绝新写入 | 串行写可以避免请求堆积和写入顺序混乱 |

补充说明:空工程允许 0 个 HMI 页面;一旦已经创建页面,用户在编辑器里不能把最后一个页面删除。校验方式只能选无校验、奇校验或偶校验,常用真机配置仍是 `COM3、9600、8E1`。
补充说明:空工程允许 0 个 HMI 页面;一旦已经创建页面,用户在编辑器里不能把最后一个页面删除。校验方式只能选无校验、奇校验或偶校验,常用真机配置仍是 `COM3、9600、8E1`。PLC 自动搜索固定使用界面当前站号,遍历全部可用 PC 串口和上述允许参数的 60 种组合,不自动穷举 `1~247` 站号。

梯形图网络只放一个线圈或其他输出指令也是合法的。它表示条件一直成立,软件每次扫描都会执行这个输出;普通线圈会一直导通,MOVE、ADD/SUB 等指令会每次扫描执行。只有条件却没有输出属于未完成网络,不能进入运行态。
用户可以直接添加线圈或其他输出指令,但模型会自动在输出前生成 10 格真实横线,即 `Wire(10) + Output`。它表示条件一直成立,软件每次扫描都会执行这个输出;普通线圈会一直导通,MOVE、ADD/SUB 等指令会每次扫描执行。只有条件却没有输出,或条件区存在断路 `Gap`,都属于未完成网络,不能进入运行态。

## 2. 不需要再单独限制的项目

| 项目 | 最终结论 | 原因 |
| --- | --- | --- |
| 当前同时触发的报警条数 | 不另设上限 | 它天然不会超过 512 条报警定义 |
| 页面跳转目标数量 | 不另设上限 | 目标只能来自最多 128 个现有页面 |
| PLC 缓存中的 M/D 数量 | 不另设上限 | 缓存由 `0~4000` 地址范围和 1024 个轮询地址共同限制 |
| 当前同时触发的报警条数 | 不另设上限 | 它天然不会超过 256 条报警定义 |
| 页面跳转目标数量 | 不另设上限 | 目标只能来自最多 32 个现有页面 |
| PLC 缓存中的 M/D 数量 | 不另设上限 | 缓存由 `0~4000` 地址范围和 256 个轮询地址共同限制 |
| 单次按钮点击、数值输入、保存和加载次数 | 不限制 | 每次操作完成后不会持续积累同类对象 |
| 软件连续运行时间 | 不限制 | 运行时间本身不是内存集合,也不是 PLC 资源 |
| 离线仿真的累计扫描次数 | 不限制 | 只保存一个 64 位计数,不保存每次扫描的完整副本 |
| 工程文件路径长度 | 不在业务层另设上限 | 交给 Windows 和 Qt 的文件接口处理,工程内容仍受 16 MiB 限制 |
| 可用 COM 端口列表数量 | 不另设上限 | 列表来自操作系统,项目不持久化累积这些端口 |
| 报警阈值、进度条范围、普通 HMI 数值的额外业务范围 | 不再加第二套范围 | 统一使用 D 的 `-32768~32767`,进度条只额外要求最小值小于最大值 |
| 加减法参与次数 | 不单独限制 | 执行量已经受 128 组逻辑、每组 1024 个网络和单网络复杂度限制 |
| 报警阈值普通 HMI 数值的额外业务范围 | 不再加第二套范围 | 统一使用 D 的 `-32768~32767` |
| 加减法参与次数 | 不单独限制 | 执行量已经受 32 组逻辑、每组 256 个网络和单网络复杂度限制 |

## 3. 这些数字从哪里来



+ 56
- 0
scripts/build_and_run_qt.ps1 Просмотреть файл

@@ -0,0 +1,56 @@
[CmdletBinding()]
param(
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Debug'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$projectRoot = Split-Path -Parent $PSScriptRoot
$qtRoot = 'D:\Qt5.15.2\5.15.2\mingw81_64'
$minGwBin = 'D:\Qt5.15.2\Tools\mingw810_64\bin'
$qmakePath = Join-Path $qtRoot 'bin\qmake.exe'
$makePath = Join-Path $minGwBin 'mingw32-make.exe'
$projectFile = Join-Path $projectRoot 'app\integrated_platform.pro'
$configurationName = $Configuration.ToLowerInvariant()
$buildDirectory = Join-Path $projectRoot "build\$configurationName"
$executablePath = Join-Path $buildDirectory "$configurationName\integrated_platform.exe"

foreach ($requiredPath in @($qmakePath, $makePath, $projectFile)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "未找到必要文件:$requiredPath"
}
}

New-Item -ItemType Directory -Path $buildDirectory -Force | Out-Null

$originalPath = $env:Path
$env:Path = "$qtRoot\bin;$minGwBin;$env:Path"

Push-Location $buildDirectory
try {
& $qmakePath $projectFile "CONFIG+=$configurationName" "CONFIG-=$(if ($configurationName -eq 'debug') { 'release' } else { 'debug' })"
if ($LASTEXITCODE -ne 0) {
throw "qmake 执行失败,退出码:$LASTEXITCODE"
}

& $makePath -j2
if ($LASTEXITCODE -ne 0) {
throw "mingw32-make 执行失败,退出码:$LASTEXITCODE"
}

if (-not (Test-Path -LiteralPath $executablePath)) {
throw "未生成可执行文件:$executablePath"
}

Write-Host "构建完成,正在启动:$executablePath"
& $executablePath
if ($LASTEXITCODE -ne 0) {
throw "程序退出异常,退出码:$LASTEXITCODE"
}
}
finally {
Pop-Location
$env:Path = $originalPath
}

+ 174
- 0
scripts/measure_cpp_code.ps1 Просмотреть файл

@@ -0,0 +1,174 @@
[CmdletBinding()]
param(
[Parameter()]
[string]$SourcePath = (Join-Path $PSScriptRoot '..\app\src'),

[Parameter()]
[switch]$ShowFiles
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-CodeLineStatistics {
param(
[Parameter(Mandatory)]
[string]$FilePath
)

$statistics = [ordered]@{
TotalLines = 0
BlankLines = 0
CommentOnlyLines = 0
PreprocessorLines = 0
CodeLines = 0
}
$inBlockComment = $false
$inRawString = $false
$rawStringTerminator = ''

foreach ($line in [System.IO.File]::ReadLines($FilePath, [System.Text.UTF8Encoding]::new($false))) {
$statistics.TotalLines++
$index = 0
$code = [System.Text.StringBuilder]::new()
$containsComment = $inBlockComment

while ($index -lt $line.Length) {
if ($inRawString) {
$terminatorIndex = $line.IndexOf($rawStringTerminator, $index, [System.StringComparison]::Ordinal)
if ($terminatorIndex -lt 0) {
[void]$code.Append($line.Substring($index))
$index = $line.Length
continue
}

[void]$code.Append($line.Substring($index, $terminatorIndex + $rawStringTerminator.Length - $index))
$index = $terminatorIndex + $rawStringTerminator.Length
$inRawString = $false
continue
}

if ($inBlockComment) {
$containsComment = $true
$commentEnd = $line.IndexOf('*/', $index, [System.StringComparison]::Ordinal)
if ($commentEnd -lt 0) {
$index = $line.Length
continue
}

$index = $commentEnd + 2
$inBlockComment = $false
continue
}

$current = $line[$index]
$next = if ($index + 1 -lt $line.Length) { $line[$index + 1] } else { [char]0 }

if ($current -eq '/' -and $next -eq '/') {
$containsComment = $true
break
}

if ($current -eq '/' -and $next -eq '*') {
$containsComment = $true
$inBlockComment = $true
$index += 2
continue
}

if ($current -eq 'R' -and $next -eq '"') {
$delimiterStart = $index + 2
$openParenthesis = $line.IndexOf('(', $delimiterStart)
if ($openParenthesis -ge 0 -and $openParenthesis - $delimiterStart -le 16) {
$delimiter = $line.Substring($delimiterStart, $openParenthesis - $delimiterStart)
if ($delimiter -notmatch '[\s\\()]') {
$rawStringTerminator = ')' + $delimiter + '"'
[void]$code.Append($line.Substring($index, $openParenthesis - $index + 1))
$index = $openParenthesis + 1
$inRawString = $true
continue
}
}
}

if ($current -eq '"' -or $current -eq "'") {
$quote = $current
[void]$code.Append($quote)
$index++
while ($index -lt $line.Length) {
$stringCharacter = $line[$index]
[void]$code.Append($stringCharacter)
$index++
if ($stringCharacter -eq '\\' -and $index -lt $line.Length) {
[void]$code.Append($line[$index])
$index++
continue
}
if ($stringCharacter -eq $quote) {
break
}
}
continue
}

[void]$code.Append($current)
$index++
}

$remaining = $code.ToString().Trim()
if ([string]::IsNullOrWhiteSpace($line)) {
$statistics.BlankLines++
}
elseif ($remaining.StartsWith('#')) {
$statistics.PreprocessorLines++
}
elseif (-not [string]::IsNullOrWhiteSpace($remaining)) {
$statistics.CodeLines++
}
elseif ($containsComment) {
$statistics.CommentOnlyLines++
}
else {
$statistics.BlankLines++
}
}

return [pscustomobject]$statistics
}

$resolvedSourcePath = (Resolve-Path -LiteralPath $SourcePath).Path
$files = Get-ChildItem -LiteralPath $resolvedSourcePath -Recurse -File -Filter '*.cpp' | Sort-Object FullName
if ($files.Count -eq 0) {
throw "未在 '$resolvedSourcePath' 找到 .cpp 文件"
}

$fileResults = foreach ($file in $files) {
$statistics = Get-CodeLineStatistics -FilePath $file.FullName
[pscustomobject]@{
File = $file.FullName.Substring($resolvedSourcePath.Length).TrimStart('\', '/')
CodeLines = $statistics.CodeLines
TotalLines = $statistics.TotalLines
BlankLines = $statistics.BlankLines
CommentOnlyLines = $statistics.CommentOnlyLines
PreprocessorLines = $statistics.PreprocessorLines
}
}

$total = [pscustomobject]@{
File = '合计'
CodeLines = [int]($fileResults | Measure-Object -Property CodeLines -Sum).Sum
TotalLines = [int]($fileResults | Measure-Object -Property TotalLines -Sum).Sum
BlankLines = [int]($fileResults | Measure-Object -Property BlankLines -Sum).Sum
CommentOnlyLines = [int]($fileResults | Measure-Object -Property CommentOnlyLines -Sum).Sum
PreprocessorLines = [int]($fileResults | Measure-Object -Property PreprocessorLines -Sum).Sum
}

Write-Host "扫描目录: $resolvedSourcePath"
Write-Host "统计范围: $($files.Count) 个 .cpp 文件,不统计 .h;移除注释及 # 预处理指令后统计代码行"
Write-Host "总行数: $($total.TotalLines)"
Write-Host "纯 C++ 代码: $($total.CodeLines) 行"
Write-Host "空白行: $($total.BlankLines) | 纯注释行: $($total.CommentOnlyLines) | 预处理行: $($total.PreprocessorLines)"

if ($ShowFiles) {
$fileResults | Format-Table File, CodeLines, TotalLines, BlankLines, CommentOnlyLines, PreprocessorLines -AutoSize
}

+ 122
- 0
scripts/package_qt_app.ps1 Просмотреть файл

@@ -0,0 +1,122 @@
[CmdletBinding()]
param(
[string]$OutputName = 'integrated_platform-win64'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$projectRoot = Split-Path -Parent $PSScriptRoot
$qtRoot = if ($env:QTDIR) { $env:QTDIR } else { 'D:\Qt5.15.2\5.15.2\mingw81_64' }
$mingwBin = 'D:\Qt5.15.2\Tools\mingw810_64\bin'
$qmakePath = Join-Path $qtRoot 'bin\qmake.exe'
$makePath = Join-Path $mingwBin 'mingw32-make.exe'
$pluginDirectory = Join-Path $qtRoot 'plugins'
$platformPlugin = Join-Path $pluginDirectory 'platforms\qwindows.dll'
$stylePlugin = Join-Path $pluginDirectory 'styles\qwindowsvistastyle.dll'
$projectFile = Join-Path $projectRoot 'app\integrated_platform.pro'
$buildDirectory = Join-Path $projectRoot 'build\package-build\release'
$executablePath = Join-Path $buildDirectory 'release\integrated_platform.exe'
$packageRoot = Join-Path $projectRoot 'build\package'

foreach ($requiredPath in @($qmakePath, $makePath, $platformPlugin, $stylePlugin, $projectFile)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "未找到必要文件:$requiredPath"
}
}

if ([string]::IsNullOrWhiteSpace($OutputName) -or
$OutputName -in @('.', '..') -or
$OutputName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 -or
[System.IO.Path]::GetFileName($OutputName) -ne $OutputName) {
throw "输出名称不是有效的 Windows 文件名:$OutputName"
}

$packageDirectory = Join-Path $packageRoot $OutputName
$archivePath = Join-Path $packageRoot "$OutputName.zip"
$resolvedProjectRoot = [System.IO.Path]::GetFullPath($projectRoot).TrimEnd('\')
$resolvedPackageRoot = [System.IO.Path]::GetFullPath($packageRoot).TrimEnd('\')
$resolvedPackageDirectory = [System.IO.Path]::GetFullPath($packageDirectory)
if (-not $resolvedPackageRoot.StartsWith("$resolvedProjectRoot\build\", [System.StringComparison]::OrdinalIgnoreCase) -or
[System.IO.Path]::GetDirectoryName($resolvedPackageDirectory) -ne $resolvedPackageRoot) {
throw "打包目录必须是 build\package 的直接子目录:$resolvedPackageDirectory"
}

New-Item -ItemType Directory -Path $buildDirectory -Force | Out-Null
New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null

$originalPath = $env:Path
$env:Path = "$qtRoot\bin;$mingwBin;$env:Path"

try {
Push-Location $buildDirectory
try {
& $qmakePath $projectFile 'CONFIG+=release' 'CONFIG-=debug'
if ($LASTEXITCODE -ne 0) {
throw "qmake 执行失败,退出码:$LASTEXITCODE"
}

& $makePath -j2
if ($LASTEXITCODE -ne 0) {
throw "mingw32-make 执行失败,退出码:$LASTEXITCODE"
}
}
finally {
Pop-Location
}

if (-not (Test-Path -LiteralPath $executablePath)) {
throw "未生成可执行文件:$executablePath"
}

if (Test-Path -LiteralPath $packageDirectory) {
Remove-Item -LiteralPath $packageDirectory -Recurse -Force
}
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}

New-Item -ItemType Directory -Path $packageDirectory | Out-Null
Copy-Item -LiteralPath $executablePath -Destination $packageDirectory

$packagedExecutable = Join-Path $packageDirectory 'integrated_platform.exe'
$runtimeFiles = @(
'Qt5Core.dll', 'Qt5Gui.dll', 'Qt5Network.dll',
'Qt5SerialBus.dll', 'Qt5SerialPort.dll', 'Qt5Widgets.dll',
'libgcc_s_seh-1.dll', 'libstdc++-6.dll', 'libwinpthread-1.dll'
)
foreach ($runtimeFile in $runtimeFiles) {
$runtimeSource = Join-Path $(if ($runtimeFile -like 'Qt5*') { "$qtRoot\bin" } else { $mingwBin }) $runtimeFile
if (-not (Test-Path -LiteralPath $runtimeSource)) {
throw "未找到运行库:$runtimeSource"
}
Copy-Item -LiteralPath $runtimeSource -Destination $packageDirectory
}

$packagedPlatformDirectory = Join-Path $packageDirectory 'platforms'
$packagedStyleDirectory = Join-Path $packageDirectory 'styles'
New-Item -ItemType Directory -Path $packagedPlatformDirectory | Out-Null
New-Item -ItemType Directory -Path $packagedStyleDirectory | Out-Null
Copy-Item -LiteralPath $platformPlugin -Destination $packagedPlatformDirectory
Copy-Item -LiteralPath $stylePlugin -Destination $packagedStyleDirectory

foreach ($requiredOutput in @(
$packagedExecutable,
(Join-Path $packageDirectory 'Qt5Core.dll'),
(Join-Path $packageDirectory 'Qt5SerialBus.dll'),
(Join-Path $packagedPlatformDirectory 'qwindows.dll')
)) {
if (-not (Test-Path -LiteralPath $requiredOutput)) {
throw "打包结果缺少必要文件:$requiredOutput"
}
}

Compress-Archive -LiteralPath $packageDirectory -DestinationPath $archivePath -CompressionLevel Optimal

Write-Host "打包完成"
Write-Host "运行目录:$packageDirectory"
Write-Host "压缩包:$archivePath"
}
finally {
$env:Path = $originalPath
}

+ 1
- 0
scripts/run_qt_tests.ps1 Просмотреть файл

@@ -34,6 +34,7 @@ $functionalTargets = @(
'register_monitor_service_tests',
'runtime_mode_service_tests',
'runtime_panel_controller_tests',
'plc_connection_dialog_tests',
'plc_runtime_tests'
)
$performanceTargets = @('performance_tests')


Загрузка…
Отмена
Сохранить