From 43e184bffb5b6d36bced1c42b27dfada19dc0609 Mon Sep 17 00:00:00 2001 From: taocong <> Date: Tue, 22 Sep 2026 15:15:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=8B=A5=E5=B9=B2=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 - ICON_GUIDE.md | 24 - README.md | 2 +- Uft30ChangeCode.pro | 73 +- build_advanced.bat | 12 +- docs/使用手册.md | 32 +- docs/详细设计文档.md | 106 ++- help/Writerside/hi.tree | 6 +- help/Writerside/topics/UF20功能查询.md | 4 +- help/Writerside/topics/update-1-6-0.md | 9 + help/Writerside/topics/下载.md | 1 + help/Writerside/topics/不转码功能函数.md | 30 + help/Writerside/topics/业务转码.md | 4 +- help/Writerside/topics/元数据更新.md | 11 +- help/Writerside/topics/功能转码.md | 19 +- help/Writerside/topics/按需转码.md | 37 + help/Writerside/topics/更新记录.md | 8 +- help/Writerside/topics/设置.md | 19 +- main.cpp | 25 +- python_bindings/INTEGRATION_GUIDE.md | 123 --- python_bindings/README.md | 20 - .../pybind11_binding/CMakeLists.txt | 44 - .../pybind11_binding/uf2touft3_binding.cpp | 81 -- resources.qrc | 7 +- resources/help/help.md | 164 ---- resources/help/tips.md | 18 - resources/icons/.gitkeep | 1 - resources/images/chaxun.png | Bin 5034 -> 0 bytes resources/images/delect.png | Bin 5995 -> 0 bytes resources/images/zhuanhuan.png | Bin 5405 -> 0 bytes setup_python_tool.bat | 47 - src/components/elasearchedit.cpp | 20 +- src/components/elasearchedit.h | 4 - src/components/interdialog.cpp | 9 +- src/components/interdialog.h | 10 - src/mainwindow/mainwindow.cpp | 175 ++-- src/mainwindow/mainwindow.h | 13 +- .../{ => common}/interface_scanner.h | 42 +- src/metadataupdate/filedb.cpp | 12 - src/metadataupdate/filedb.h | 17 - src/metadataupdate/metadataprocessor.cpp | 894 ++++++++---------- src/metadataupdate/metadataprocessor.h | 146 ++- .../{ => uf20}/uf2constants.cpp | 11 +- src/metadataupdate/{ => uf20}/uf2constants.h | 0 src/metadataupdate/{ => uf20}/uf2datatype.cpp | 9 +- src/metadataupdate/{ => uf20}/uf2datatype.h | 0 .../{ => uf20}/uf2errnumber.cpp | 9 +- src/metadataupdate/{ => uf20}/uf2errnumber.h | 0 src/metadataupdate/uf20/uf2interface.cpp | 349 +++++++ src/metadataupdate/{ => uf20}/uf2interface.h | 12 +- .../{ => uf20}/uf2menuandfunction.cpp | 4 +- .../{ => uf20}/uf2menuandfunction.h | 0 src/metadataupdate/{ => uf20}/uf2module.cpp | 11 +- src/metadataupdate/uf20/uf2module.h | 31 + .../{ => uf20}/uf2stdfields.cpp | 17 +- src/metadataupdate/{ => uf20}/uf2stdfields.h | 0 src/metadataupdate/{ => uf20}/uf2table.cpp | 86 +- src/metadataupdate/{ => uf20}/uf2table.h | 6 + src/metadataupdate/uf2interface.cpp | 415 -------- src/metadataupdate/uf2module.h | 23 - .../{ => uft30}/uft3datatype.cpp | 21 +- src/metadataupdate/{ => uft30}/uft3datatype.h | 0 .../{ => uft30}/uft3interface.cpp | 131 +-- .../{ => uft30}/uft3interface.h | 18 +- .../{ => uft30}/uft3stdfields.cpp | 15 +- .../{ => uft30}/uft3stdfields.h | 0 src/metadataupdate/{ => uft30}/uft3table.cpp | 75 +- src/metadataupdate/uft30/uft3table.h | 33 + src/metadataupdate/uft3table.h | 25 - src/pages/batchconvert/batchconvertpage.cpp | 674 ++----------- src/pages/batchconvert/batchconvertpage.h | 88 +- src/pages/codesearch/codesearchpage.cpp | 227 +++-- src/pages/codesearch/codesearchpage.h | 12 +- src/pages/common/convertworkbenchbase.cpp | 664 +++++++++++++ src/pages/common/convertworkbenchbase.h | 150 +++ src/pages/datamapping/functionmappingpage.cpp | 585 +----------- src/pages/datamapping/functionmappingpage.h | 54 +- src/pages/datamapping/mappingtablepage.cpp | 315 ++++++ src/pages/datamapping/mappingtablepage.h | 117 +++ .../datamapping/nochangefullnamepage.cpp | 137 +++ src/pages/datamapping/nochangefullnamepage.h | 28 + src/pages/datamapping/tablemappingpage.cpp | 579 +----------- src/pages/datamapping/tablemappingpage.h | 51 +- .../datamapping/twocolumnkvmappingpage.cpp | 141 +++ .../datamapping/twocolumnkvmappingpage.h | 38 + .../functionsearch/functionsearchpagebase.cpp | 230 +++++ .../functionsearch/functionsearchpagebase.h | 66 ++ .../functionsearch/uf20functionsearchpage.cpp | 220 +---- .../functionsearch/uf20functionsearchpage.h | 41 +- .../functionsearch/uft3functionsearchpage.cpp | 159 +--- .../functionsearch/uft3functionsearchpage.h | 31 +- .../metadataupdatepage.cpp} | 417 ++++---- .../metadataupdatepage.h} | 35 +- src/pages/nonlsconvert/nonlsconvertpage.cpp | 822 ++++------------ src/pages/nonlsconvert/nonlsconvertpage.h | 109 +-- src/pages/settings/settingspage.cpp | 298 ++---- src/pages/settings/settingspage.h | 5 +- src/pythonrunner/PythonRunner.cpp | 138 +-- src/pythonrunner/PythonRunner.h | 46 +- src/utils/Constants.h | 130 ++- src/utils/business_strategy.cpp | 73 -- src/utils/business_strategy.h | 47 - src/utils/callchainanalyzer.cpp | 270 +++--- src/utils/callchainanalyzer.h | 24 +- src/utils/configmanager.cpp | 195 ++-- src/utils/configmanager.h | 17 +- src/utils/configreaderbase.cpp | 27 +- src/utils/configreaderbase.h | 9 +- src/utils/custjsonio.cpp | 190 ++++ src/utils/custjsonio.h | 46 + src/utils/datacache.cpp | 401 ++++---- src/utils/datacache.h | 88 +- src/utils/jsonfilemonitor.cpp | 133 +++ src/utils/jsonfilemonitor.h | 66 ++ src/utils/logmanager.cpp | 81 +- src/utils/logmanager.h | 12 +- src/utils/uf2configreader.cpp | 13 +- src/utils/uft3configreader.cpp | 19 +- 118 files changed, 5518 insertions(+), 6372 deletions(-) delete mode 100644 ICON_GUIDE.md create mode 100644 help/Writerside/topics/update-1-6-0.md create mode 100644 help/Writerside/topics/不转码功能函数.md create mode 100644 help/Writerside/topics/按需转码.md delete mode 100644 python_bindings/INTEGRATION_GUIDE.md delete mode 100644 python_bindings/README.md delete mode 100644 python_bindings/pybind11_binding/CMakeLists.txt delete mode 100644 python_bindings/pybind11_binding/uf2touft3_binding.cpp delete mode 100644 resources/help/help.md delete mode 100644 resources/help/tips.md delete mode 100644 resources/icons/.gitkeep delete mode 100644 resources/images/chaxun.png delete mode 100644 resources/images/delect.png delete mode 100644 resources/images/zhuanhuan.png delete mode 100644 setup_python_tool.bat rename src/metadataupdate/{ => common}/interface_scanner.h (68%) delete mode 100644 src/metadataupdate/filedb.cpp delete mode 100644 src/metadataupdate/filedb.h rename src/metadataupdate/{ => uf20}/uf2constants.cpp (74%) rename src/metadataupdate/{ => uf20}/uf2constants.h (100%) rename src/metadataupdate/{ => uf20}/uf2datatype.cpp (89%) rename src/metadataupdate/{ => uf20}/uf2datatype.h (100%) rename src/metadataupdate/{ => uf20}/uf2errnumber.cpp (82%) rename src/metadataupdate/{ => uf20}/uf2errnumber.h (100%) create mode 100644 src/metadataupdate/uf20/uf2interface.cpp rename src/metadataupdate/{ => uf20}/uf2interface.h (52%) rename src/metadataupdate/{ => uf20}/uf2menuandfunction.cpp (96%) rename src/metadataupdate/{ => uf20}/uf2menuandfunction.h (100%) rename src/metadataupdate/{ => uf20}/uf2module.cpp (84%) create mode 100644 src/metadataupdate/uf20/uf2module.h rename src/metadataupdate/{ => uf20}/uf2stdfields.cpp (78%) rename src/metadataupdate/{ => uf20}/uf2stdfields.h (100%) rename src/metadataupdate/{ => uf20}/uf2table.cpp (59%) rename src/metadataupdate/{ => uf20}/uf2table.h (64%) delete mode 100644 src/metadataupdate/uf2interface.cpp delete mode 100644 src/metadataupdate/uf2module.h rename src/metadataupdate/{ => uft30}/uft3datatype.cpp (69%) rename src/metadataupdate/{ => uft30}/uft3datatype.h (100%) rename src/metadataupdate/{ => uft30}/uft3interface.cpp (73%) rename src/metadataupdate/{ => uft30}/uft3interface.h (58%) rename src/metadataupdate/{ => uft30}/uft3stdfields.cpp (78%) rename src/metadataupdate/{ => uft30}/uft3stdfields.h (100%) rename src/metadataupdate/{ => uft30}/uft3table.cpp (75%) create mode 100644 src/metadataupdate/uft30/uft3table.h delete mode 100644 src/metadataupdate/uft3table.h create mode 100644 src/pages/common/convertworkbenchbase.cpp create mode 100644 src/pages/common/convertworkbenchbase.h create mode 100644 src/pages/datamapping/mappingtablepage.cpp create mode 100644 src/pages/datamapping/mappingtablepage.h create mode 100644 src/pages/datamapping/nochangefullnamepage.cpp create mode 100644 src/pages/datamapping/nochangefullnamepage.h create mode 100644 src/pages/datamapping/twocolumnkvmappingpage.cpp create mode 100644 src/pages/datamapping/twocolumnkvmappingpage.h create mode 100644 src/pages/functionsearch/functionsearchpagebase.cpp create mode 100644 src/pages/functionsearch/functionsearchpagebase.h rename src/pages/{metadatupdate/metadatupdatepage.cpp => metadataupdate/metadataupdatepage.cpp} (57%) rename src/pages/{metadatupdate/metadatupdatepage.h => metadataupdate/metadataupdatepage.h} (66%) delete mode 100644 src/utils/business_strategy.cpp delete mode 100644 src/utils/business_strategy.h create mode 100644 src/utils/custjsonio.cpp create mode 100644 src/utils/custjsonio.h create mode 100644 src/utils/jsonfilemonitor.cpp create mode 100644 src/utils/jsonfilemonitor.h diff --git a/.gitignore b/.gitignore index d42d9a1..b3bacfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ * !3rd/ !3rd/** -!python_bindings/ -!python_bindings/** !src/ !src/** !help/ diff --git a/ICON_GUIDE.md b/ICON_GUIDE.md deleted file mode 100644 index 23936eb..0000000 --- a/ICON_GUIDE.md +++ /dev/null @@ -1,24 +0,0 @@ -# 图标设置说明 - -## 已完成配置 - -1. **应用程序窗口图标** - 已设置为 `resources/icons/转码工具.svg` -2. **资源文件** - 图标已添加到 `resources.qrc` - -## Windows 可执行程序图标(可选) - -如需设置 Windows 可执行文件的图标(任务栏、文件管理器显示的图标),需要: - -### 方法 1:使用在线工具转换 -1. 将 `resources/images/转码工具.png` 转换为 `.ico` 格式 -2. 推荐尺寸:包含 16x16, 32x32, 48x48, 256x256 等多种尺寸 -3. 保存为 `app.ico` 并放在项目根目录 -4. `app.rc` 已配置引用 `app.ico` - -### 方法 2:使用 ImageMagick 转换 -```bash -magick convert 转码工具.png -define icon:auto-resize=256,128,96,64,48,32,16 app.ico -``` - -### 重新编译 -设置好图标后重新编译项目即可。 diff --git a/README.md b/README.md index 8f773f7..3a09673 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Uft30ChangeCode/ │ └─ change_code.ini # 默认配置模板(构建时按需部署到 bin/uf2touft3/) ├─ src/ │ ├─ mainwindow/ # 主窗口 -│ ├─ pages/ # 各功能页面(batchconvert / codesearch / functionsearch / metadatupdate / datamapping / settings / about) +│ ├─ pages/ # 各功能页面(batchconvert / codesearch / functionsearch / metadataupdate / datamapping / settings / about) │ ├─ metadataupdate/ # 元数据扫描与处理(UF2 / UFT3 接口、数据类型、字段等) │ ├─ pythonrunner/ # 调用外部转码工具(uf2touft3.exe) │ ├─ components/ # 自定义控件 diff --git a/Uft30ChangeCode.pro b/Uft30ChangeCode.pro index 33bcdda..301ba0f 100644 --- a/Uft30ChangeCode.pro +++ b/Uft30ChangeCode.pro @@ -3,45 +3,48 @@ QT += core gui widgets sql network concurrent TARGET = Uft30ChangeCode TEMPLATE = app -DEFINES += QT_DEPRECATED_WARNINGS - SOURCES += main.cpp \ src/mainwindow/mainwindow.cpp \ src/pythonrunner/PythonRunner.cpp \ src/pages/about/aboutdialog.cpp \ src/pages/businessconvert/businessconvertpage.cpp \ + src/pages/common/convertworkbenchbase.cpp \ src/pages/batchconvert/batchconvertpage.cpp \ src/pages/nonlsconvert/nonlsconvertpage.cpp \ src/pages/codesearch/codesearchpage.cpp \ + src/pages/functionsearch/functionsearchpagebase.cpp \ src/pages/functionsearch/uf20functionsearchpage.cpp \ src/pages/functionsearch/uft3functionsearchpage.cpp \ src/components/elasearchedit.cpp \ src/components/interdialog.cpp \ src/pages/settings/settingspage.cpp \ - src/pages/metadatupdate/metadatupdatepage.cpp \ + src/pages/metadataupdate/metadataupdatepage.cpp \ + src/pages/datamapping/twocolumnkvmappingpage.cpp \ src/pages/datamapping/tablemappingpage.cpp \ src/pages/datamapping/functionmappingpage.cpp \ - src/metadataupdate/filedb.cpp \ - src/metadataupdate/uf2interface.cpp \ - src/metadataupdate/uft3interface.cpp \ + src/pages/datamapping/nochangefullnamepage.cpp \ + src/pages/datamapping/mappingtablepage.cpp \ + src/metadataupdate/uf20/uf2interface.cpp \ + src/metadataupdate/uft30/uft3interface.cpp \ src/metadataupdate/metadataprocessor.cpp \ - src/metadataupdate/uf2datatype.cpp \ - src/metadataupdate/uf2stdfields.cpp \ - src/metadataupdate/uf2errnumber.cpp \ - src/metadataupdate/uf2constants.cpp \ - src/metadataupdate/uf2menuandfunction.cpp \ - src/metadataupdate/uf2module.cpp \ - src/metadataupdate/uft3datatype.cpp \ - src/metadataupdate/uft3stdfields.cpp \ - src/metadataupdate/uft3table.cpp \ - src/metadataupdate/uf2table.cpp \ + src/metadataupdate/uf20/uf2datatype.cpp \ + src/metadataupdate/uf20/uf2stdfields.cpp \ + src/metadataupdate/uf20/uf2errnumber.cpp \ + src/metadataupdate/uf20/uf2constants.cpp \ + src/metadataupdate/uf20/uf2menuandfunction.cpp \ + src/metadataupdate/uf20/uf2module.cpp \ + src/metadataupdate/uft30/uft3datatype.cpp \ + src/metadataupdate/uft30/uft3stdfields.cpp \ + src/metadataupdate/uft30/uft3table.cpp \ + src/metadataupdate/uf20/uf2table.cpp \ src/utils/datacache.cpp \ - src/utils/business_strategy.cpp \ src/utils/configreaderbase.cpp \ src/utils/uf2configreader.cpp \ src/utils/uft3configreader.cpp \ src/utils/logmanager.cpp \ src/utils/configmanager.cpp \ + src/utils/custjsonio.cpp \ + src/utils/jsonfilemonitor.cpp \ src/utils/callchainanalyzer.cpp \ src/utils/version.cpp \ src/utils/versionchecker.cpp @@ -50,38 +53,44 @@ HEADERS += src/mainwindow/mainwindow.h \ src/pythonrunner/PythonRunner.h \ src/pages/about/aboutdialog.h \ src/pages/businessconvert/businessconvertpage.h \ + src/pages/common/convertworkbenchbase.h \ src/pages/batchconvert/batchconvertpage.h \ src/pages/nonlsconvert/nonlsconvertpage.h \ src/pages/codesearch/codesearchpage.h \ + src/pages/functionsearch/functionsearchpagebase.h \ src/pages/functionsearch/uf20functionsearchpage.h \ src/pages/functionsearch/uft3functionsearchpage.h \ src/components/elasearchedit.h \ src/components/interdialog.h \ src/pages/settings/settingspage.h \ - src/pages/metadatupdate/metadatupdatepage.h \ + src/pages/metadataupdate/metadataupdatepage.h \ + src/pages/datamapping/twocolumnkvmappingpage.h \ src/pages/datamapping/tablemappingpage.h \ src/pages/datamapping/functionmappingpage.h \ - src/metadataupdate/filedb.h \ - src/metadataupdate/uf2interface.h \ - src/metadataupdate/uft3interface.h \ + src/pages/datamapping/nochangefullnamepage.h \ + src/pages/datamapping/mappingtablepage.h \ + src/metadataupdate/common/interface_scanner.h \ + src/metadataupdate/uf20/uf2interface.h \ + src/metadataupdate/uft30/uft3interface.h \ src/metadataupdate/metadataprocessor.h \ - src/metadataupdate/uf2datatype.h \ - src/metadataupdate/uf2stdfields.h \ - src/metadataupdate/uf2errnumber.h \ - src/metadataupdate/uf2constants.h \ - src/metadataupdate/uf2menuandfunction.h \ - src/metadataupdate/uf2module.h \ - src/metadataupdate/uft3datatype.h \ - src/metadataupdate/uft3stdfields.h \ - src/metadataupdate/uft3table.h \ - src/metadataupdate/uf2table.h \ + src/metadataupdate/uf20/uf2datatype.h \ + src/metadataupdate/uf20/uf2stdfields.h \ + src/metadataupdate/uf20/uf2errnumber.h \ + src/metadataupdate/uf20/uf2constants.h \ + src/metadataupdate/uf20/uf2menuandfunction.h \ + src/metadataupdate/uf20/uf2module.h \ + src/metadataupdate/uft30/uft3datatype.h \ + src/metadataupdate/uft30/uft3stdfields.h \ + src/metadataupdate/uft30/uft3table.h \ + src/metadataupdate/uf20/uf2table.h \ src/utils/datacache.h \ - src/utils/business_strategy.h \ src/utils/configreaderbase.h \ src/utils/uf2configreader.h \ src/utils/uft3configreader.h \ src/utils/logmanager.h \ src/utils/configmanager.h \ + src/utils/custjsonio.h \ + src/utils/jsonfilemonitor.h \ src/utils/callchainanalyzer.h \ src/utils/version.h \ src/utils/versionchecker.h diff --git a/build_advanced.bat b/build_advanced.bat index 038649f..509c60d 100644 --- a/build_advanced.bat +++ b/build_advanced.bat @@ -149,13 +149,15 @@ if exist "bin\uf2touft3\uf2touft3.exe" ( echo. REM Deploy default change_code.ini (only copy when missing, to keep user's existing config) +REM 注意: 代码读取路径为 bin/uf2touft3/config/change_code.ini (Constants::changeCodeIni), +REM 必须部署到 config 子目录, 与 build.bat 保持一致 echo Deploying default change_code.ini... -if not exist "bin\uf2touft3" mkdir "bin\uf2touft3" -if not exist "bin\uf2touft3\change_code.ini" ( - copy "config\change_code.ini" "bin\uf2touft3\change_code.ini" >nul - echo [OK] change_code.ini -> bin\uf2touft3\ (default copied) +if not exist "bin\uf2touft3\config" mkdir "bin\uf2touft3\config" +if not exist "bin\uf2touft3\config\change_code.ini" ( + copy "config\change_code.ini" "bin\uf2touft3\config\change_code.ini" >nul + echo [OK] change_code.ini -> bin\uf2touft3\config (default copied) ) else ( - echo [SKIP] bin\uf2touft3\change_code.ini already exists, keep user config + echo [SKIP] bin\uf2touft3\config\change_code.ini already exists, keep user config ) echo. pause diff --git a/docs/使用手册.md b/docs/使用手册.md index 90cc3ca..1c9c95a 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -187,7 +187,7 @@ Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提 ### 5.4 数据映射 -**说明**:数据映射维护 UF20 → UFT3 的**替换对照表**,转码引擎按映射替换代码中的表名 / 函数名。两类映射均保存在 `cust.json`(见 §6.1);界面修改后点 **保存** 写盘生效,外部编辑 `cust.json` 保存后界面自动刷新(程序自身保存后的 2 秒内暂停文件监控,此间的外部修改需再次保存文件才会被检测到)。 +**说明**:数据映射维护 UF20 → UFT3 的**替换对照表**与**不转码清单**,转码引擎按映射替换代码中的表名 / 函数名,清单内的功能函数名则跳过替换。各页数据均保存在 `cust.json`(见 §6.1);界面修改后点 **保存** 写盘生效,外部编辑 `cust.json` 保存后界面自动刷新(程序自身保存后的 2 秒内暂停文件监控,此间的外部修改需再次保存文件才会被检测到)。 #### 5.4.1 表替换 @@ -206,6 +206,21 @@ Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提 维护 UF20 函数名 → UFT3 函数名的映射,操作方式与表替换完全相同。 +#### 5.4.3 不转码功能函数 + +维护**不参与函数名替换**的功能函数清单,对应 `cust.json` 顶层键 `noChangeFullNameCode`(字符串数组)。转码时,清单内的功能函数名保持原样,不做 UF20 → UFT3 全名替换。 + +**操作步骤:** + +1. 进入 **数据映射 → 不转码功能函数**; +2. 顶部搜索框实时过滤条目(不区分大小写); +3. **双击单元格**直接修改; +4. 点 **添加** 弹窗输入功能函数名(必填,重复时保存被拦截提示); +5. **删除选中** 需二次确认(支持多选); +6. 修改后点 **保存** 生效;保存按原文件顶层键顺序写回 `cust.json`,不影响其它配置。 + +> **典型用途**:某功能在 UFT3 中名称未变化、或不希望被批量替换时,将其加入本清单即可跳过替换。 + ### 5.5 系统设置(底部"设置") **环境配置**: @@ -215,10 +230,12 @@ Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提 | UFT30项目环境路径 | UFT30 项目根目录 | | UF20项目环境路径 | UF20 项目根目录 | | UF20账户项目环境路径 | UF20 账户项目根目录 | +| UF20清算项目环境路径 | UF20 清算参数项目目录,供清算相关元数据处理使用 | | 转码生成路径 | 转码结果输出目录 | - 路径保存在 `config\config.ini` 的 `[projectPath]` 节,供元数据更新与转码引擎使用; - 保存时程序自动同步维护 `uft30pub = \upub\dev_codes`; +- 保存与恢复默认均**保留 config.ini 中的手工注释、空行与键顺序**,只更新对应配置项的值(程序运行期间手工编辑的配置也不会被保存动作覆盖,仅界面显式修改过的项以界面为准); - **元数据更新(§5.1)按这些路径扫描源码,首次使用必须先配置**。 **系统配置**: @@ -235,13 +252,14 @@ Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提 ### 6.1 cust.json - **路径**:`bin\uf2touft3\config\cust.json` -- **作用**:一个文件承载三类配置——全量转码清单、表替换映射、函数替换映射。 +- **作用**:一个文件承载四类配置——全量转码清单、表替换映射、函数替换映射、不转码功能函数清单。 | 节点 | 结构 | 用途 | | --- | --- | --- | | `convert.dir` | 数组,元素为 `"函数名.service_design"` | 全量转码清单 | | `tableCompareUf2_Uf3` | 对象 `{UF20表名: UFT3表名}` | 表替换映射 | | `functionReplace` | 对象 `{UF20函数名: UFT3函数名}` | 函数替换映射 | +| `noChangeFullNameCode` | 字符串数组 | 不转码功能函数清单,转码时跳过这些函数名的替换 | **手工编辑示例(以实际文件结构为准):** @@ -258,13 +276,17 @@ Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提 }, "functionReplace": { "UF20函数1": "UFT3函数1" - } + }, + "noChangeFullNameCode": [ + "AF_NoChange1", + "LF_NoChange2" + ] } ``` **编辑注意:** -- 程序内修改(全量转码 / 表替换 / 函数替换页)会自动写回该文件; +- 程序内修改(全量转码 / 表替换 / 函数替换 / 不转码功能函数页)会自动写回该文件; - 手工编辑保存后各页面自动检测并刷新; - 全量转码清单函数名可省略 `.service_design` 后缀展示,`dir` 为数组结构,程序保存时保留文件其余内容与格式。 @@ -308,7 +330,7 @@ Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提 ### 6.3 config.ini 与 change_code.ini -- **`bin\uf2touft3\config\config.ini`**:`[projectPath]` 节保存设置页的项目环境路径(`uft30` / `uf20` / `ufAcct20` / `createPath` / `uft30pub`),供元数据更新与转码引擎使用; +- **`bin\uf2touft3\config\config.ini`**:`[projectPath]` 节保存设置页的项目环境路径(`uft30` / `uf20` / `ufAcct20` / `UftdbSett` / `createPath` / `uft30pub`),供元数据更新与转码引擎使用; - **`bin\uf2touft3\config\change_code.ini`**:程序私有配置(与 config.ini 分离,避免程序更新时被覆盖): - `[log]` 节:日志配置(`file`:日志基础文件名,默认 `change_code.log`,输出到 exe 目录 `logs\` 下,文件名带当天日期前缀); - `codeSearch` 节:代码检索两种模式的检索路径持久化。 diff --git a/docs/详细设计文档.md b/docs/详细设计文档.md index e1485d0..65eaf87 100644 --- a/docs/详细设计文档.md +++ b/docs/详细设计文档.md @@ -23,8 +23,8 @@ Uft30ChangeCode 是一款桌面端 **UF20 → UFT3 代码转码工具**,提供 - **业务转码**:**全量转码**(LS_ 功能清单式整体转码,清单存 `cust.json`)与**按需转码**(非 LS 功能,单文件/递归两种模式,清单存 `cust_nonls.json`,转码范围不含核心文件、module 模块文件),转码动作由外部引擎 `bin/uf2touft3/uf2touft3.exe` 执行; - **代码检索**:功能**调用链路查询**(向上调用方、向下被调),双模式(转码业务/非转码业务),支持导出调用文件与链路文本; - **功能查询**:检索 UF20 / UFT3 功能元数据,支持**多关键词批量查询**(逗号/分号/空格分隔,OR 并集);UF20 查询结果**双击/右键一键分流**——可多选,LS_ 功能加入全量转码、其它功能加入按需转码,加入结果合并为单次提示;UFT3 仅用于查询核对; -- **数据映射**:维护 UF20 → UFT3 的表替换、函数替换对照表(存 `cust.json`,转码引擎按映射替换代码中的表名/函数名); -- **系统设置**:项目环境路径与主题配置(元数据更新与转码引擎依赖这些路径)。 +- **数据映射**:维护 UF20 → UFT3 的表替换、函数替换对照表与**不转码功能函数清单**(存 `cust.json`,转码引擎按映射替换代码中的表名/函数名,清单内功能函数名跳过替换); +- **系统设置**:项目环境路径(含 UF20清算项目环境路径)与主题配置(元数据更新与转码引擎依赖这些路径)。 ### 1.3 术语约定 @@ -136,13 +136,13 @@ Uft30ChangeCode/ │ │ ├── nonlsconvert/ # 按需转码页 │ │ ├── codesearch/ # 代码检索页(调用链路查询) │ │ ├── functionsearch/ # 功能查询(FunctionSearchPage 基类 + UF20/UFT3 子页) -│ │ ├── datamapping/ # 数据映射(表替换 / 函数替换) -│ │ ├── metadatupdate/ # 元数据更新页 +│ │ ├── datamapping/ # 数据映射(表替换 / 函数替换 / 不转码功能函数) +│ │ ├── metadataupdate/ # 元数据更新页 │ │ ├── settings/ # 设置页 │ │ └── about/ # 关于(AboutDialog 对话框) │ └── utils/ # Constants.h / business_strategy / configreaderbase │ # / uf2configreader / uft3configreader / callchainanalyzer -│ # / datacache / configmanager / logmanager +│ # / datacache / configmanager / logmanager / custjsonio │ # / version / versionchecker ├── 3rd/ElaWidgetTools/ # 第三方 UI 库 ├── bin/ @@ -165,9 +165,9 @@ Uft30ChangeCode/ | 按需转码 | `NonLsConvertPage` | — | 非 LS 功能清单管理、递归模式选择、外部修改同步 | | 功能查询 | `FunctionSearchPage`(基类)、`UF20/UFT3FunctionSearchPage` | 模板方法(基类复用) | 元数据检索(多关键词批量查询);UF20 双击/右键一键分流加入转码清单(合并单次提示),UFT3 纯查询 | | 代码检索 | `CodeSearchPage`、`CallChainAnalyzer` | — | 调用链路查询、索引构建与缓存、导出 | -| 数据映射 | `TableMappingPage`、`FunctionMappingPage` | — | `cust.json` 表替换/函数替换映射维护 | +| 数据映射 | `TableMappingPage`、`FunctionMappingPage`、`NoChangeFullNamePage` | — | `cust.json` 表替换/函数替换映射维护、不转码功能函数清单维护(增删改查) | | 元数据更新 | `MetadataUpdatePage` + `src/metadataupdate/` 解析器组 | 观察者(信号槽) | 后台解析源码、进度上报、原子落库 | -| 设置 | `SettingsPage` | — | 项目环境路径、主题配置 | +| 设置 | `SettingsPage` | — | 项目环境路径(含 UF20清算项目环境路径 `UftdbSett`)、主题配置 | | 缓存 | `DataCache` | 单例 | QSqlDatabase 双库读写、功能查询内存缓存 | | 引擎调用 | `PythonRunner` | — | 外部进程封装(打包 exe / Python 脚本双模式) | | 日志 | `LogManager` | 单例 | 统一日志 | @@ -185,7 +185,7 @@ flowchart TD A --> B[初始化 LogManager 日志单例] B --> C[初始化 ConfigManager 读取全局配置] C --> D[构造 MainWindow ElaWindow] - D --> E["注册导航页面
功能转码:业务转码 / 代码检索
功能查询:UF20 / UFT3
数据映射:表替换 / 函数替换
元数据更新;底部:设置 / 帮助 / 关于"] + D --> E["注册导航页面
功能转码:业务转码 / 代码检索
功能查询:UF20 / UFT3
数据映射:表替换 / 函数替换 / 不转码功能函数
元数据更新;底部:设置 / 帮助 / 关于"] E --> F[DataCache.initDatabase
打开 uf20_cache.db / uft3_cache.db] F --> G{缓存库是否有效?} G -- 是 --> H[正常进入主界面] @@ -200,7 +200,7 @@ flowchart TD ### 3.2 页面导航机制 -- `MainWindow` 基于 `ElaWindow` 实现,侧边栏按分组注册页面:**功能转码**(业务转码、代码检索)、**功能查询**(UF20、UFT3)、**数据映射**(表替换、函数替换)、**元数据更新**;底部为设置、帮助、关于; +- `MainWindow` 基于 `ElaWindow` 实现,侧边栏按分组注册页面:**功能转码**(业务转码、代码检索)、**功能查询**(UF20、UFT3)、**数据映射**(表替换、函数替换、不转码功能函数)、**元数据更新**;底部为设置、帮助、关于; - 业务转码页内部通过 ElaPivot 切换全量/按需两个工作台; - 页面切换由 ElaWindow 内部路由完成,各页面通过 `showEvent` 感知可见性变化(如按需转码页每次显示时刷新表格,代码检索页每次显示时重读 `cust.json` 清单并按需构建索引)。 @@ -562,33 +562,36 @@ flowchart TD - **导出调用链路**(非转码业务):仅生成 `_call_chain.txt`,不复制源文件; - 目录名经 `sanitizeDirName` 清洗 Windows 非法字符。 -### 5.6 数据映射模块(TableMappingPage / FunctionMappingPage) +### 5.6 数据映射模块(MappingTablePage 基类 + TableMappingPage / FunctionMappingPage / NoChangeFullNamePage) -**职责**:维护 UF20 → UFT3 的**表替换**(`tableCompareUf2_Uf3`)与**函数替换**(`functionReplace`)映射,两页代码同构,均读写 `cust.json` 对应节点;转码引擎按映射替换代码中的表名/函数名。 +**职责**:维护 UF20 → UFT3 的**表替换**(`tableCompareUf2_Uf3`)与**函数替换**(`functionReplace`)映射,以及**不转码功能函数清单**(`noChangeFullNameCode`,字符串数组——转码时清单内功能函数名保持原样、不做全名替换)。三页共同继承公共基类 `MappingTablePage`(UI 骨架收编),读写 `cust.json` 对应顶层键经 `CustJsonIo`(见 §6.8)与 `Constants::CustKeys`(见 §6.4);转码引擎按映射替换代码中的表名/函数名。 + +**MappingTablePage 公共基类**(`src/pages/datamapping/mappingtablepage.h/.cpp`):收编三页原本同构的 UI 骨架与交互样板——搜索框 + `QStandardItemModel` + 参数化列数的 `MappingFilterProxyModel`(不区分大小写 contains 过滤)+ `ElaTableView` + 添加/删除选中/保存按钮 + tip 标签 + `JsonFileMonitor`(见 §6.9)+ 主题联动。三页仅以纯虚点注入差异: + +| 虚点 | 说明 | +| --- | --- | +| `populateModel(custJson)` | 从 cust.json 填充模型(`load()` 内调用) | +| `collectMappingData(custJson, errorMessage)` | 保存时校验并回填数据,返回 `overrideRaw`(键名→格式化原文);校验失败置 errorMessage(弹警告不落盘) | +| `onAddRow()` | 添加一行(各页数据结构不同:对象条目 / 字符串数组) | + +可覆写文案虚点:`loadFailureText` / `saveLogText` / `reloadLogText` / `deleteConfirmSingle` / `deleteConfirmMulti`。子类在构造体内调用非虚的 `load()`(规避构造期虚函数绑定到基类版本)。基类统一实现:删除多选二次确认、就地编辑脏标志(`m_dirty`,`onModelItemChanged` 中 `m_isLoading` 保护)、保存流程(见下时序)、外部修改重载(脏数据不覆盖、弹确认)与 tip 样式联动。 ```mermaid flowchart TD - A([进入表替换 / 函数替换页]) --> B["readCustJson 读取映射节点"] - B --> C["渲染表格(QStandardItemModel)
QSortFilterProxyModel 两列不区分大小写过滤"] + A([进入 表替换 / 函数替换 / 不转码功能函数页]) --> B["readCustJson 读取映射节点"] + B --> C["populateModel 渲染表格
QSortFilterProxyModel 不区分大小写过滤"] C --> D{用户操作} - D -- 双击 / F2 编辑 --> E[更新模型] - D -- 添加 --> F["弹窗:UF20 名必填 + 查重"] - D -- 删除选中 --> G[二次确认(支持多选)] - E --> H[点击保存] + D -- 双击 / F2 编辑 --> E[更新模型 + 置脏标志] + D -- 添加 --> F["onAddRow:弹窗名称必填 + 查重"] + D -- 删除选中 --> G["onDeleteRow:二次确认(支持多选)"] + E --> H["onSave:beginSaveWindow"] F --> H G --> H - H --> I["读取原始文本
extractTopLevelKeyOrder 提取顶层 key 顺序"] - I --> J["writeCustJson 按序写回:
本映射节点有序输出,其余节点原样保留"] - J --> K["保存后 2s 内忽略 fileChanged
(QTimer)并重新 addPath"] - L[外部编辑 cust.json] -. fileChanged .-> M[loadData 立即刷新] + H --> I["collectMappingData 校验回填
读取原始文本提取顶层 key 顺序"] + I --> J["CustJsonIo::writeCustJsonOrdered 按序原子写回
endSave 结束保存窗口"] + K[外部编辑 cust.json] -. JsonFileMonitor::reloadRequested .-> L["onReloadRequested:脏数据弹确认
否则 loadData 立即刷新"] ``` -**要点:** - -- 保存采用**保留顶层 key 顺序的整体重写**:先从原始文本用正则提取 key 出现顺序,再按顺序写出,避免 JSON 重排破坏手工维护的文件结构; -- 自身保存后 2 秒内的 `fileChanged` 事件被忽略(防回环),此后恢复监听; -- 添加弹窗校验:UF20 名非空且不与现有行重复。 - ### 5.7 元数据更新页面(MetadataUpdatePage) **职责**:元数据更新的人机入口——勾选更新范围、触发更新、展示进度与统计结果(见 §4.1 时序图)。 @@ -604,9 +607,10 @@ flowchart TD | 配置项 | 说明 | | --- | --- | | UFT30 / UF20 / UF20账户项目环境路径 | 元数据更新与转码引擎的源码扫描根目录 | +| UF20清算项目环境路径 | UF20 清算参数项目目录,写 `config.ini` 的 `[projectPath] UftdbSett`,供清算相关元数据处理使用 | | 转码生成路径 | 转码结果输出目录 | -- 路径保存于 `config.ini` 的 `[projectPath]` 节;保存时程序自动同步维护 `uft30pub = \upub\dev_codes`; +- 路径保存于 `config.ini` 的 `[projectPath]` 节(含 `UftdbSett`);"保存"与"恢复默认"统一经 `ConfigManager::saveConfig()` **保留布局式写回**(手工注释/键顺序/外部新增键不丢失,见 §6.3),程序自动同步维护 `uft30pub = \upub\dev_codes`; - 主题模式(日间/夜间)即时切换;"恢复默认"需二次确认。 --- @@ -629,8 +633,9 @@ flowchart TD ### 6.3 ConfigManager(单例) -- 管理 `config.ini`(`[projectPath]` 节),启动时全量加载至内存缓存; -- `setValue()` 更新缓存并立即回写文件;读取侧复用 `ConfigReaderBase`(QSettings 兼容读取,加载过程经 LogManager 记录)。 +- 管理 `config.ini`,启动时按节全量加载至内存缓存(UTF8/本地编码探测);路径白名单键(`uft30` / `uf20` / `ufAcct20` / `UftdbSett` / `createPath` / `uft30pub`)加载时统一归一化为正斜杠; +- `setValue()` 仅更新内存缓存并记录脏键(缓存中路径键恒为正斜杠,与加载侧对称,任何来源不再漂移),由设置页显式调 `saveConfig()` 落盘; +- `saveConfig()` 为**保留布局式写回**:以磁盘当前内容为基底逐行输出,仅替换"显式 setValue 过"的键行(写盘时换回反斜杠),**手工注释、空行、节顺序、外部新增键一律原样保留**,缓存中有而文件没有的键在对应节尾追加;落盘前经 mtime 探测外部手编,对应用未显式修改过的键采纳磁盘值(外部已有键的修改不被内存覆盖),并以 `QSaveFile` 原子写。 ### 6.4 Constants.h(全局常量) @@ -639,6 +644,7 @@ flowchart TD | ToolDir / ChangeCodeDir / FunctionOutput / CacheDir | `uf2touft3` / `change_code` / `function_output` / `cache` | 引擎目录与输出子目录 | | Exe | `uf2touft3.exe` | 转码引擎可执行文件名 | | CustJson / CustNonLsJson | `config/cust.json` / `config/cust_nonls.json` | 转码配置文件 | +| CustKeys::ConvertDir / TableCompare / FunctionReplace / NoChangeFullName | `convert` / `tableCompareUf2_Uf3` / `functionReplace` / `noChangeFullNameCode` | cust.json 顶层键名集中定义(含 cust_nonls.json 的 `convert`),数据映射三页与转码工作台统一引用,避免散落字符串字面量 | | Uf20DbFile / Uft3DbFile | `uf20_cache.db` / `uft3_cache.db` | 双缓存库文件名 | | ChangeCodeIni | `change_code.ini` | 路径持久化与日志配置文件 | | CodeSearch::ConvertPathKey / SearchPathKey | `codeSearch/convertPath` / `codeSearch/searchPath` | 代码检索路径配置键 | @@ -673,6 +679,33 @@ flowchart TD - 启动时经 QNetworkAccessManager 拉取 `update_info.json`,解析多版本发布列表,取 `Version::compare` 比较后的最大版本为最新版; - 与当前版本比较后发射 `newVersionAvailable(latestVersion, releaseNotes)` / `checkError(msg)` / `noUpdateNeeded()`,MainWindow 弹出更新提示,"关于"页亦可手动检查。 +### 6.8 CustJsonIo(cust.json 保序读写工具) + +命名空间级公共工具(`src/utils/custjsonio.h/.cpp`),供数据映射三个页面(表替换 / 函数替换 / 不转码功能函数)共用,消除原先两份重复实现: + +| 函数 | 说明 | +| --- | --- | +| `extractTopLevelKeyOrder(rawJson)` | 深度感知扫描原始 JSON 文本(跳过字符串字面量),仅记录 depth==1 时后跟冒号的顶层键,返回键顺序;正则方案无法匹配中文/特殊字符键且会误捕嵌套键,故弃用 | +| `indentJsonValue(json)` | 对 JSON 文本整体缩进(每行前加 4 空格) | +| `formatJsonValue(value)` | 通用 JSON 值格式化:对象/数组走 `QJsonDocument::Indented` 再整体缩进,字符串手工转义,其余按 bool/number/null | +| `formatOrderedJsonObject(pairs)` | 按给定顺序格式化 string→string 对象(条目 8 空格缩进),供映射页输出 | +| `formatOrderedJsonArray(items)` | 按给定顺序格式化字符串数组(条目 8 空格缩进,空数组输出 `[]`),供不转码功能函数页输出 | +| `writeCustJsonOrdered(jsonPath, json, keyOrder, overrideRaw)` | `QSaveFile` 原子写:按 keyOrder 顺序输出顶层键,`overrideRaw` 中的键用调用方自定义格式化文本重写,其余键走 `formatJsonValue`;保存失败记录错误日志 | + +### 6.9 JsonFileMonitor(JSON 配置外部修改监控组件) + +公共监控组件(`src/utils/jsonfilemonitor.h/.cpp`),收编原先散落在 5 个页面(数据映射三页 + 全量转码页 + 非转码业务页)的**文件+目录双监控样板**——每处约 50 行的 QFileSystemWatcher 事件处理、防抖、回环过滤逻辑统一为一个可复用组件,页面侧仅剩 `watch()` + `connect(reloadRequested)` + 保存时 `beginSaveWindow()/endSave()` 三行。 + +| 成员/方法 | 说明 | +| --- | --- | +| `watch(filePath)` | 切换监控目标:文件 + 所在目录双监控(Windows 下外部程序整文件替换会使文件句柄失效,目录事件兜底补挂) | +| `refreshWatch()` | 补挂监控(fileChanged 后文件路径可能已从 watcher 移除,防抖触发前先补挂) | +| `currentMtime()` | 当前文件 mtime 基准(文件不存在返回 0) | +| `beginSaveWindow()` / `endSave()` | 保存窗口:窗口期内监控事件直接忽略;`endSave()` 立即提升保存代数(`m_saveSeq`),使窗口内已排队的防抖事件全部过期,替代旧实现"保存后固定 2 秒忽略"的硬等待 | +| `reloadRequested()` 信号 | 外部修改确认(300ms 防抖 + mtime 基准过滤自身写入回环后)单发;页面侧检查脏标志后重载 | + +**回环过滤原理**:`beginSaveWindow()` 预置当前 mtime 为基准;监控事件到达时若 `mtime <= 基准` 或处于保存窗口/保存代数过期,视为自身写入引发的变化,不触发重载——自身保存经 `endSave()` 提升基准后,外部手编(mtime 变大)才会命中。 + --- ## 7. 数据设计 @@ -690,10 +723,11 @@ flowchart TD | --- | --- | --- | | `tableCompareUf2_Uf3` | UF20→UFT3 表名映射 | 全量/按需转码的表替换 | | `functionReplace` | 函数替换规则 | 转码的函数替换 | +| `noChangeFullNameCode` | 字符串数组(功能函数全名清单) | 转码引擎对清单内功能函数名不做全名替换 | | 其余键 | 引擎自身配置 | 原样透传 | -- 数据映射两页各自只读写所属节,其余内容原样保留; -- 程序保存时经 `extractTopLevelKeyOrder` 提取顶层 key 顺序、`writeCustJson` 按原顺序写回,不破坏 JSON 键序。 +- 数据映射三页各自只读写所属顶层键(键名统一引用 `Constants::CustKeys`,见 §6.4),其余内容原样保留; +- 程序保存时经 `CustJsonIo::extractTopLevelKeyOrder` 提取顶层 key 顺序、`CustJsonIo::writeCustJsonOrdered` 按原顺序 `QSaveFile` 原子写回,不破坏 JSON 键序(见 §6.8)。 ### 7.3 cust_nonls.json(非转码业务配置) @@ -714,7 +748,7 @@ flowchart TD | 文件 | 节/键 | 说明 | | --- | --- | --- | -| config.ini | `[projectPath]` | UFT30/UF20/项目环境路径、转码生成路径;保存时同步维护 `uft30pub = \upub\dev_codes` | +| config.ini | `[projectPath]` | UFT30/UF20/UF20账户/清算项目环境路径(`uft30` / `uf20` / `ufAcct20` / `UftdbSett`)、转码生成路径;保存时同步维护 `uft30pub = \upub\dev_codes` | | change_code.ini | `[log]` | 日志级别/开关,LogManager 启动时读取 | --- @@ -769,7 +803,7 @@ MainWindow 启动即校验 `bin/uf2touft3/uf2touft3.exe` 是否存在:缺失 | --- | --- | | `Uft30ChangeCode.exe` | 主程序 | | `uf2touft3/uf2touft3.exe` | 转码引擎(外部进程) | -| `uf2touft3/config/cust.json` | 表/函数映射配置(数据映射页读写) | +| `uf2touft3/config/cust.json` | 表/函数映射与不转码功能函数清单配置(数据映射页读写) | | `uf2touft3/config/cust_nonls.json` | 非转码业务配置(业务转码页读写) | | `uf2touft3/cache/uf20_cache.db` / `uft3_cache.db` | 元数据缓存库(首次元数据更新生成) | | `config.ini` / `change_code.ini` | 程序路径配置 / 日志配置 | @@ -786,7 +820,7 @@ MainWindow 启动即校验 `bin/uf2touft3/uf2touft3.exe` 是否存在:缺失 | 业务转码 | `src/pages/businessconvert/`(容器)+ `src/pages/batchconvert/`(全量)+ `src/pages/nonlsconvert/`(按需) | | 功能查询 | `src/pages/functionsearch/`(UF20 / UFT3 双模式) | | 代码检索 | `src/pages/codesearch/`(含 CallChainAnalyzer) | -| 数据映射 | `src/pages/datamapping/`(TableMappingPage / FunctionMappingPage) | +| 数据映射 | `src/pages/datamapping/`(TableMappingPage / FunctionMappingPage / NoChangeFullNamePage) | | 元数据更新 | `src/pages/metadataupdate/`(页面)+ `src/metadataupdate/`(解析器组) | | 设置 / 关于 | `src/pages/settings/` / `src/pages/about/` | -| 公共支撑 | `src/utils/`(DataCache、LogManager、ConfigManager、VersionChecker、Constants 等)、`src/pythonrunner/`、`src/components/` | \ No newline at end of file +| 公共支撑 | `src/utils/`(DataCache、LogManager、ConfigManager、CustJsonIo、VersionChecker、Constants 等)、`src/pythonrunner/`、`src/components/` | \ No newline at end of file diff --git a/help/Writerside/hi.tree b/help/Writerside/hi.tree index 8fd3eb8..ba3ab54 100644 --- a/help/Writerside/hi.tree +++ b/help/Writerside/hi.tree @@ -9,7 +9,9 @@ - + + + @@ -19,9 +21,11 @@ + + diff --git a/help/Writerside/topics/UF20功能查询.md b/help/Writerside/topics/UF20功能查询.md index f182d30..43d6033 100644 --- a/help/Writerside/topics/UF20功能查询.md +++ b/help/Writerside/topics/UF20功能查询.md @@ -19,6 +19,6 @@ - 无匹配结果 → 提示"⚠️ 未找到匹配的功能" - 查询成功 → 表格展示结果,tip 栏恢复默认操作提示 -## 双击添加 -双击选中行加入转码工作台:LS_ 开头的功能进入"全量转码",其它功能进入"按需转码" +## 添加到转码工作台 +双击或右键选中行加入转码工作台:LS_ 开头的功能进入"全量转码",其它功能进入"按需转码" 双击添加 diff --git a/help/Writerside/topics/update-1-6-0.md b/help/Writerside/topics/update-1-6-0.md new file mode 100644 index 0000000..043ddc1 --- /dev/null +++ b/help/Writerside/topics/update-1-6-0.md @@ -0,0 +1,9 @@ +# 1.6.0 + +1.转码逻辑优化; + +2.元数据更新逻辑优化; + +3.元数据更新存储修改为db存储模式; + +4.写日志异常修复; diff --git a/help/Writerside/topics/下载.md b/help/Writerside/topics/下载.md index 8dfcc1b..9f17763 100644 --- a/help/Writerside/topics/下载.md +++ b/help/Writerside/topics/下载.md @@ -14,6 +14,7 @@ | 版本 | 更新日期 | 全量包 | 增量包 | |-------|------------|----------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| 1.6.0 | 2026-09-18 | [Uft30ChangeCode-1.6.0.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.6.0.zip) | [Uft30ChangeCode-1.6.0-update.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.6.0-update.zip) | | 1.5.0 | 2026-08-07 | [Uft30ChangeCode-1.5.0.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.5.0.zip) | [Uft30ChangeCode-1.5.0-update.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.5.0-update.zip) | | 1.4.0 | 2026-07-21 | [Uft30ChangeCode-1.4.0.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.4.0.zip) | [Uft30ChangeCode-1.4.0-update.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.4.0-update.zip) | | 1.3.0 | 2026-07-07 | [Uft30ChangeCode-1.3.0.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.3.0.zip) | [Uft30ChangeCode-1.3.0-update.zip](http://10.20.163.105:6045/download/uft3changecode/Uft30ChangeCode-1.3.0-update.zip) | diff --git a/help/Writerside/topics/不转码功能函数.md b/help/Writerside/topics/不转码功能函数.md new file mode 100644 index 0000000..9a0e45d --- /dev/null +++ b/help/Writerside/topics/不转码功能函数.md @@ -0,0 +1,30 @@ +# 不转码功能函数 + +维护一份功能全名清单:转码时清单中的功能全名**保持原名不做替换**。入口:左侧导航"数据映射" > "不转码功能函数"。 + +## 添加条目 + +点击"添加"按钮,在弹窗中输入功能全名(如 `AF_用户公用_用户可操作分支获取`)后确定。 + +- 功能全名不能为空; +- 不允许与已有条目重复,重复时提示后不会加入。 + +## 编辑与删除 + +双击表格单元格可直接修改条目。 + +选中行后点击"删除选中",确认后移除所选条目。 + +## 保存 + +点击"保存"按钮将清单写回 `bin/uf2touft3/config/cust.json`。 + +保存前会校验: + +- 存在空条目时报错并中止,需填写或删除该行; +- 存在重复条目时报错并中止,需修改后再保存。 + +## 配置文件同步 + +- 清单持久化于 `cust.json` 的不转码功能函数数组中,与其他数据映射配置共用同一文件; +- `cust.json` 被外部修改时,清单自动刷新(未保存的修改会被覆盖,请先保存再改外部文件)。 diff --git a/help/Writerside/topics/业务转码.md b/help/Writerside/topics/业务转码.md index 4519e53..eee0514 100644 --- a/help/Writerside/topics/业务转码.md +++ b/help/Writerside/topics/业务转码.md @@ -10,12 +10,12 @@ 点击表格操作按钮可编辑函数名,如下: 编辑 -选中行后点击"删除"移除函数、点击"清空"清空所有函数,如下: +选中行后点击"删除选中"移除函数、点击"清空列表"清空所有函数,如下: 删除 ## 配置文件同步 -- 自动读取 uf2touft3/cust.json 配置 +- 自动读取 bin/uf2touft3/config/cust.json 配置 - 配置文件外部修改时自动刷新列表 ## 转换操作 diff --git a/help/Writerside/topics/元数据更新.md b/help/Writerside/topics/元数据更新.md index a53498d..1f6a20b 100644 --- a/help/Writerside/topics/元数据更新.md +++ b/help/Writerside/topics/元数据更新.md @@ -5,21 +5,20 @@ ## 项目配置 需要先配置UFT30项目路径和UF20项目路径。 -在设置-首选项页面,配置UFT30项目路径,例如: -``在设置-首选项页面,配置UFT30项目路径,例如:`` +在设置页面,配置UFT30项目路径,例如: +``E:\05.code\03.GIT\feature_ses`` UFT3项目环境路径 -在设置-首选项页面,配置UF20项目路径,例如: +在设置页面,配置UF20项目路径,例如: ``E:\05.code\04.SVN\secu\dep1\hstrade20\trunk\Sources\DevCodes\经纪业务运营平台V21`` UF20项目环境路径 ## UFT3元数据 -在 元数据更新 页面,选择更新范围(标准字段、接口数据等)并点击更新UFT3按钮完成元数据更新。 +在 元数据更新 页面,选择更新范围(数据类型、标准字段、表结构、接口数据)并点击"更新UFT30"按钮完成元数据更新。 UFT3元数据 **备注:接口数据更新比较久** ## 更新UF20元数据 -在 元数据更新 页面,选择更新范围(标准字段、标准组件等)并点击更新UF2按钮完成公共资源更新 +在 元数据更新 页面,选择更新范围(数据类型、标准字段、标准错误号、接口数据、常量、菜单功能、模块、表结构)并点击"更新UF20"按钮完成公共资源更新 UF20元数据 **备注:接口数据更新比较久** - diff --git a/help/Writerside/topics/功能转码.md b/help/Writerside/topics/功能转码.md index 1ee15db..926ad23 100644 --- a/help/Writerside/topics/功能转码.md +++ b/help/Writerside/topics/功能转码.md @@ -5,30 +5,35 @@ 在使用UFT3功能转码前,需要先配置UFT30项目路径,并更新UFT3、UF20元数据 1.配置项目路径 -- UFT3项目环境路径 +- UFT30项目环境路径 - 在设置-首选项页面,配置UFT30项目路径,转码使用,例如: + 在设置页面,配置UFT30项目路径,转码使用,例如: ``E:\05.code\03.GIT\feature_ses`` UFT3项目环境路径 - UF20项目环境路径 - 在设置-首选项页面,UF20项目环境路径, 转码需要读取相关的文件, 例如: + 在设置页面,UF20项目环境路径, 转码需要读取相关的文件, 例如: ``E:\05.code\04.SVN\secu\dep1\hstrade20\trunk\Sources\DevCodes\经纪业务运营平台V21`` UF20项目环境路径 -- UF20账户环境路径 +- UF20账户项目环境路径 - 在设置-首选项页面,配置UF20账户环境路径, 转码需要读取账户相关的文件,例如: + 在设置页面,配置UF20账户项目环境路径, 转码需要读取账户相关的文件,例如: ``E:\05.code\04.SVN\secu\dep1\hstrade20\trunk\Sources\DevCodes_Acct1\客户账户管理系统V22`` UF20账户环境路径 + +- UF20清算项目环境路径 + + 在设置页面,配置UF20清算项目环境路径,引擎按其生成清算相关元数据,按实际工程目录选择对应清算项目目录。 + - 转码生成路径 - 在设置-首选项页面,配置转码生成路径, 用于存放生成的转码文件, 例如: + 在设置页面,配置转码生成路径, 用于存放生成的转码文件, 例如: ``F:\07.tools\01.hstools\Uft30ChangeCode\bin\uf2touft3\change_code`` 转码生成路径 ## 快速跳转 [业务转码](http://10.20.163.105:6045/uft3changecode/业务转码.html) -[代码检索](http://10.20.163.105:6045/uft3changecode/业务转码.html) +[代码检索](http://10.20.163.105:6045/uft3changecode/代码检索.html) diff --git a/help/Writerside/topics/按需转码.md b/help/Writerside/topics/按需转码.md new file mode 100644 index 0000000..aca1ac9 --- /dev/null +++ b/help/Writerside/topics/按需转码.md @@ -0,0 +1,37 @@ +# 按需转码 + +针对**非 LS 功能**的按需转码工作台。入口:左侧导航"业务转码"页,顶部标签切换到"按需转码"。 + +转码范围不包含核心文件、module 模块文件。支持两种模式: + +- **非递归模式**:仅对目标文件本身执行转码,不会处理该文件内调用的函数; +- **递归模式**:转码目标文件的同时,自动对文件中所调用的关联函数一并完成转码。 + +## 功能列表管理 + +点击"添加"按钮,在弹窗中每行输入一个函数名(短名,如 AS_xxx),支持批量粘贴。若误带设计器扩展名后缀(如 .service_design)会自动剥离。 + +对于以 LS_ 开头的函数,界面会以黄色文字提示,并弹窗建议放入"全量转码"工作台转码(不拦截添加)。 + +选中行后点击"删除选中"移除函数、点击"清空列表"清空所有函数。 + +## 递归模式 + +每行行尾提供"是否递归"下拉框: + +- **否**:非递归模式(默认); +- **是**:递归模式,自动转码关联函数。 + +切换后立即生效并保存。 + +## 配置文件同步 + +- 自动读取 `bin/uf2touft3/config/cust_nonls.json` 配置,"是否递归"对应文件中 convert 节点的 dir 值(0=否、-1=是); +- 兼容旧版清单格式(纯函数名数组,默认按"否"加载); +- 配置文件外部修改时自动刷新列表。 + +## 转换操作 + +点击"开始转换"启动转换任务,转换过程中可查看进度条和状态信息;点击"停止转换"可中止当前任务。 + +工具以按需转码模式调用引擎,函数清单与递归开关由引擎直接读取 `cust_nonls.json`,无需手动同步。 diff --git a/help/Writerside/topics/更新记录.md b/help/Writerside/topics/更新记录.md index 8e64e3d..df8bbaf 100644 --- a/help/Writerside/topics/更新记录.md +++ b/help/Writerside/topics/更新记录.md @@ -1,3 +1,9 @@ # 更新记录 -Start typing here... \ No newline at end of file +- [1.6.0](update-1-6-0.md):转码逻辑优化、元数据更新逻辑优化、元数据更新存储修改为db存储模式、写日志异常修复 +- [1.5.0](update-1-5-0.md):解决转码遇到公共已转函数导致失败部分接口转码失败问题 +- [1.4.0](update-1-4-0.md):业务转码性能提升、元数据更新支持增量/强制全量、新增代码检索与调用链分析、全局文字可选中复制 +- [1.3.0](update-1-3-0.md):修复设置路径不生效、修复元数据更新后函数检查状态异常 +- [1.2.0](update-1-2-0.md):新增数据映射页面(表映射、函数映射)、新增版本更新提示功能 +- [1.1.0](update-1-1-0.md):支持2.0账户功能查询及业务转码 +- [1.0.0](update-1-0-0.md):首个正式版本,包含业务转码、功能查询、元数据更新、在线帮助等基础功能 diff --git a/help/Writerside/topics/设置.md b/help/Writerside/topics/设置.md index 2673f2a..3a86e5a 100644 --- a/help/Writerside/topics/设置.md +++ b/help/Writerside/topics/设置.md @@ -1,27 +1,28 @@ # 设置 +## UFT30项目环境路径 - -## UFT3项目环境路径 - - 在设置-首选项页面,配置UFT30项目路径,转码使用,例如: + 在设置页面,配置UFT30项目路径,转码使用,例如: ``E:\05.code\03.GIT\feature_ses`` ## UF20项目环境路径 {id="uf20_1"} - 在设置-首选项页面,UF20项目环境路径, 转码需要读取相关的文件, 例如: + 在设置页面,UF20项目环境路径, 转码需要读取相关的文件, 例如: ``E:\05.code\04.SVN\secu\dep1\hstrade20\trunk\Sources\DevCodes\经纪业务运营平台V21`` -## UF20账户环境路径 +## UF20账户项目环境路径 - 在设置-首选项页面,配置UF20账户环境路径, 转码需要读取账户相关的文件,例如: + 在设置页面,配置UF20账户项目环境路径, 转码需要读取账户相关的文件,例如: ``E:\05.code\04.SVN\secu\dep1\hstrade20\trunk\Sources\DevCodes_Acct1\客户账户管理系统V22`` +## UF20清算项目环境路径 + + 在设置页面,配置UF20清算项目环境路径,引擎按其生成清算相关元数据,按实际工程目录选择对应清算项目目录。 + ## 转码生成路径 - 在设置-首选项页面,配置转码生成路径, 用于存放生成的转码文件, 例如: + 在设置页面,配置转码生成路径, 用于存放生成的转码文件, 例如: ``F:\07.tools\01.hstools\Uft30ChangeCode\bin\uf2touft3\change_code`` ## 主题切换 包含日间模式和夜间模式。 - \ No newline at end of file diff --git a/main.cpp b/main.cpp index bb7d50f..bb43f19 100644 --- a/main.cpp +++ b/main.cpp @@ -3,6 +3,7 @@ #include "ElaTheme.h" #include "src/utils/configmanager.h" #include "src/utils/logmanager.h" +#include "src/utils/Constants.h" #include "src/utils/version.h" #include #include @@ -113,6 +114,12 @@ int main(int argc, char *argv[]) QApplication a(argc, argv); a.setWindowIcon(QIcon(":resources/images/ChangeCode.png")); + // 日志初始化提前到单实例检测之前: 双实例退出路径的警告必须能落盘, + // 否则 writeLog 的文件分支静默跳过, "检测到已有实例"只上控制台无痕迹。 + // QApplication 已就绪, initLogConfig 依赖的 applicationDirPath 可用 + LogManager::instance()->initLogConfig(); + LogManager::instance()->initLogFile(); + // 单实例保护: 双开会并发互写日志文件、并发覆盖 config.ini, 还会重复弹出升级提示。 // QLockFile 持有方进程崩溃后残留锁可被下次启动自动接管(按锁内 PID 探测存活) const QString lockPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) @@ -156,14 +163,16 @@ int main(int argc, char *argv[]) // 排除用户输入事件: 启动阶段积压的鼠标/键盘事件若被分发, 可能触发意料之外的 // 焦点/点击行为(例如误点即将出现的窗口), 只处理绘制类事件保证 splash 正常上屏 a.processEvents(QEventLoop::ExcludeUserInputEvents); + // 强制全量同步重绘: show 后首个 paint 可能只覆盖部分暴露区域(分块 flush), + // 而后续 MainWindow 构造期约 340ms 事件循环饥饿, 未 flush 的区域保持 + // 未初始化白色, 表现为启动画面"上半蓝、下半白"残影 + splash.update(); + splash.repaint(); // 安装全局过滤器:所有页面文字 / 表格树单元格允许用户复制 TextSelectableFilter textSelectableFilter; a.installEventFilter(&textSelectableFilter); - // 日志初始化提前到启动最前: 此后所有分段耗时都能落盘, 便于排查启动瓶颈 - LogManager::instance()->initLogConfig(); - LogManager::instance()->initLogFile(); mark("QApplication 就绪"); eApp->init(); @@ -173,15 +182,15 @@ int main(int argc, char *argv[]) // 必须在主窗口构造前设置, 保证主窗口首帧即为正确主题, 避免启动后闪色。 // 此前主题从未被应用(设置页 onThemeChanged 为空), 界面始终是 Ela 默认夜间模式 { - QSettings settings("UFT30", "ChangeCode"); - const QString theme = settings.value("Theme", "夜间模式").toString(); + QSettings settings(Constants::AppSettings::Org, Constants::AppSettings::App); + const QString theme = settings.value(Constants::AppSettings::ThemeKey, + Constants::AppSettings::ThemeDark).toString(); ElaTheme::getInstance()->setThemeMode( - theme == QString::fromUtf8("日间模式") ? ElaThemeType::Light : ElaThemeType::Dark); + theme == Constants::AppSettings::ThemeLight ? ElaThemeType::Light : ElaThemeType::Dark); mark("主题模式应用完成"); } - QString basePath = QCoreApplication::applicationDirPath(); - ConfigManager::instance()->loadConfig(basePath); + ConfigManager::instance()->loadConfig(); mark("config.ini 加载完成"); int exitCode = 0; diff --git a/python_bindings/INTEGRATION_GUIDE.md b/python_bindings/INTEGRATION_GUIDE.md deleted file mode 100644 index e942d6f..0000000 --- a/python_bindings/INTEGRATION_GUIDE.md +++ /dev/null @@ -1,123 +0,0 @@ -# Python 项目集成指南 - -本指南说明如何将 `F:/04.python/04.python/uf2touft3` 项目与 Qt 应用程序集成。 - -## 两个方案对比 - -### 方案一:增强的 QProcess 方案 (推荐用于生产环境) - -**优点:** -- 简单稳定,不易出错 -- 进程隔离,不会影响主应用程序 -- Python 依赖问题独立解决 -- 调试方便 - -**缺点:** -- 进程间通信开销 -- 无法直接共享内存 - -**状态:已实现并集成 ✅** - -### 方案二:pybind11 嵌入方案 (高级选项) - -**优点:** -- 更紧密的集成 -- 可以直接调用 Python 函数 -- 更好的性能 - -**缺点:** -- 配置复杂 -- 需要 C++ 编译环境 -- 调试困难 -- Python 全局解释器锁 (GIL) 问题 - -**状态:提供示例代码 ⚠️** - -## 使用方案一 (推荐) - -### 1. 确保 Python 环境可用 - -确保系统已安装 Python 3.8+,并且可以在命令行中运行: -```bash -python --version -``` - -### 2. 确保 Python 项目完整 - -确保 `F:/04.python/04.python/uf2touft3` 目录包含所有必要文件: -- `main.py` -- `processCombination.py` -- `config.ini` -- 其他依赖的模块和配置文件 - -### 3. 使用 Qt 应用程序 - -直接运行编译好的 `Uft30ChangeCode.exe`,点击"开始转换"按钮即可调用 Python 代码。 - -## 构建方案二 (高级) - -如果你想使用 pybind11 方案,按以下步骤: - -### 1. 安装依赖 - -```bash -pip install pybind11 -``` - -### 2. 创建构建目录 - -```bash -cd python_bindings/pybind11_binding -mkdir build -cd build -``` - -### 3. 配置 CMake - -```bash -cmake .. -G "MinGW Makefiles" -# 或使用 Visual Studio -# cmake .. -G "Visual Studio 16 2019" -A x64 -``` - -### 4. 编译 - -```bash -cmake --build . --config Release -``` - -### 5. 集成到 Qt 项目 - -在你的 Qt 代码中动态加载编译好的库。 - -## Python 项目的依赖 - -确保你的 Python 项目所需的所有依赖都已安装: -```bash -pip install -r requirements.txt -``` - -*(需要在 uf2touft3 项目中创建 requirements.txt)* - -## 故障排查 - -### Python 无法启动 -- 检查 Python 是否在 PATH 中 -- 尝试设置完整的 Python 路径 -- 检查是否有多个 Python 版本 - -### 模块导入错误 -- 确保工作目录设置正确 -- 检查所有依赖的模块是否都在 Python 路径中 -- 检查 `config.ini` 中的路径配置是否正确 - -### 中文路径问题 -- 确保所有路径使用 UTF-8 编码 -- 避免在路径中使用特殊字符 - -## 自定义配置 - -如需修改 Python 脚本路径,可以编辑 `mainwindow.cpp` 中的配置: -```cpp -QString scriptPath = "F:/04.python/04.python/uf2touft3/main.py"; -``` diff --git a/python_bindings/README.md b/python_bindings/README.md deleted file mode 100644 index ca0e948..0000000 --- a/python_bindings/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Python Bindings for UFT30 Change Code - -本目录包含将 Python 项目编译为可被 Qt 调用的库的相关代码。 - -## 方案一:使用 pybind11 (推荐用于高性能集成) - -此方案创建一个 C++ 库,直接嵌入 Python 解释器并调用 Python 代码。 - -## 方案二:增强的 QProcess 方案 (推荐用于简单集成) - -此方案保持使用子进程调用,但增加更好的错误处理、进度显示等功能。 - -## 构建说明 - -### 前置要求 - -- Python 3.8+ (开发版,包含头文件) -- CMake 3.15+ -- Visual Studio 2019+ 或 MinGW-w64 -- pybind11 (可以通过 pip 安装) diff --git a/python_bindings/pybind11_binding/CMakeLists.txt b/python_bindings/pybind11_binding/CMakeLists.txt deleted file mode 100644 index 74d9e7d..0000000 --- a/python_bindings/pybind11_binding/CMakeLists.txt +++ /dev/null @@ -1,44 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(uf2touft3_binding VERSION 1.0.0 LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -# 查找 Python -find_package(Python3 COMPONENTS Interpreter Development REQUIRED) - -# 尝试查找 pybind11 -# 方法 1: 通过 pip 安装的 pybind11 -execute_process( - COMMAND ${Python3_EXECUTABLE} -m pybind11 --cmakedir - OUTPUT_VARIABLE PYBIND11_CMAKE_DIR - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET -) - -if(PYBIND11_CMAKE_DIR) - list(APPEND CMAKE_PREFIX_PATH ${PYBIND11_CMAKE_DIR}) -endif() - -find_package(pybind11 REQUIRED) - -# 创建 Python 模块 -pybind11_add_module(uf2touft3_binding uf2touft3_binding.cpp) - -# Windows 特定设置 -if(WIN32) - set_target_properties(uf2touft3_binding PROPERTIES - SUFFIX ".pyd" - ) -endif() - -# 安装目标 -install(TARGETS uf2touft3_binding - LIBRARY DESTINATION . - RUNTIME DESTINATION . -) - -message(STATUS "Python3 executable: ${Python3_EXECUTABLE}") -message(STATUS "Python3 include dirs: ${Python3_INCLUDE_DIRS}") -message(STATUS "Python3 libraries: ${Python3_LIBRARIES}") -message(STATUS "pybind11 found: ${pybind11_FOUND}") diff --git a/python_bindings/pybind11_binding/uf2touft3_binding.cpp b/python_bindings/pybind11_binding/uf2touft3_binding.cpp deleted file mode 100644 index abf648e..0000000 --- a/python_bindings/pybind11_binding/uf2touft3_binding.cpp +++ /dev/null @@ -1,81 +0,0 @@ -#include -#include -#include -#include - -namespace py = pybind11; - -// 全局解释器持有对象 -py::scoped_interpreter* g_python_interpreter = nullptr; - -// 初始化 Python 解释器 -extern "C" __declspec(dllexport) bool InitializePython() -{ - try { - if (!g_python_interpreter) { - g_python_interpreter = new py::scoped_interpreter(); - } - return true; - } catch (const std::exception& e) { - std::cerr << "Failed to initialize Python: " << e.what() << std::endl; - return false; - } -} - -// 清理 Python 解释器 -extern "C" __declspec(dllexport) void FinalizePython() -{ - if (g_python_interpreter) { - delete g_python_interpreter; - g_python_interpreter = nullptr; - } -} - -// 执行 doWork 函数 -extern "C" __declspec(dllexport) bool RunConversion(const char* script_path) -{ - try { - if (!g_python_interpreter) { - std::cerr << "Python interpreter not initialized!" << std::endl; - return false; - } - - // 添加脚本路径到 sys.path - py::module sys = py::module::import("sys"); - py::object path = sys.attr("path"); - - // 获取脚本目录 - std::string script_dir = script_path; - size_t last_sep = script_dir.find_last_of("/\\"); - if (last_sep != std::string::npos) { - script_dir = script_dir.substr(0, last_sep); - } - - // 添加到 sys.path - path.attr("insert")(0, script_dir); - - // 导入脚本模块 - py::module script_module = py::module::import("main"); - - // 调用 doWork 函数 - script_module.attr("doWork")(); - - return true; - } catch (const py::error_already_set& e) { - std::cerr << "Python error: " << e.what() << std::endl; - return false; - } catch (const std::exception& e) { - std::cerr << "Error: " << e.what() << std::endl; - return false; - } -} - -// 简单的 C API 封装 -PYBIND11_MODULE(uf2touft3, m) { - m.doc() = "UFT2 to UFT3 code conversion module"; - - m.def("initialize", &InitializePython, "Initialize Python interpreter"); - m.def("finalize", &FinalizePython, "Finalize Python interpreter"); - m.def("run_conversion", &RunConversion, "Run the conversion process", - py::arg("script_path")); -} diff --git a/resources.qrc b/resources.qrc index 287bbd3..702b27e 100644 --- a/resources.qrc +++ b/resources.qrc @@ -2,12 +2,7 @@ resources/images/ChangeCode.png resources/images/bianji.png - resources/images/zhedie_left.png - resources/images/zhedie_right.png - resources/images/max.png - resources/images/min.png - resources/images/update.png resources/images/expand_all.png resources/images/collapse_all.png - \ No newline at end of file + diff --git a/resources/help/help.md b/resources/help/help.md deleted file mode 100644 index cfc10c2..0000000 --- a/resources/help/help.md +++ /dev/null @@ -1,164 +0,0 @@ -# 使用帮助 - -## 概述 - -欢迎使用 UFT30 Change Code 工具!这是一款强大的编码转换工具,支持批量转码和非LS格式转换。 - -### 功能导航 - -``` -UFT30 Change Code -├── 📁 功能转码 -│ └── 业务转码 -├── 📁 功能查询 -│ ├── UF20功能查询 -│ └── UFT3功能查询 -├── 元数据更新 -├── 设置 -├── 关于 -└── 使用技巧 -``` - -点击左侧导航栏的功能项,查看详细的使用说明。 - -## 功能转码 - -### 业务转码 - -将UF20函数转换为UFT3函数格式的核心功能。 - -#### 功能列表管理 - -- 点击"添加"按钮,输入函数名添加到转码列表 -- 双击表格行可编辑函数名 -- 选中行后点击"删除"移除函数 -- 点击"清空"清空所有函数 - -#### 配置文件同步 - -- 自动读取 uf2touft3/cust.json 配置 -- 配置文件外部修改时自动刷新列表 - -#### 转换操作 - -- 点击"开始转换"启动转换任务 -- 转换过程中可查看进度条和状态信息 -- 点击"停止转换"中止当前任务 - -## 功能查询 - -### UF20功能查询 - -查询UF20系统中的函数信息。 - -#### 查询条件 - -- 从下拉框选择查询字段:功能名称、英文名称、功能编号 -- 输入框输入查询关键词 -- 点击"Aa"切换精确/模糊匹配模式 -- 点击放大镜图标或按回车执行查询 - -#### 结果操作 - -- 双击查询结果可直接添加到转码列表 -- 结果表格显示:功能名称、英文名称、功能编号 - -### UFT3功能查询 - -查询UFT3系统中的函数信息。 - -#### 查询条件 - -- 从下拉框选择查询字段:功能名称、英文名称、功能编号 -- 输入框输入查询关键词 -- 点击"Aa"切换精确/模糊匹配模式 -- 点击放大镜图标或按回车执行查询 - -#### 结果显示 - -- 结果表格显示:功能名称、英文名称、功能编号 - -## 元数据更新 - -更新UF20和UFT3系统的元数据配置。 - -### UF20配置更新 - -- 勾选需要更新的UF20接口项 -- 点击"全选"选中所有UF20项 -- 点击"取消"清除UF20选择 -- 点击"更新UF20"执行更新 - -### UFT3配置更新 - -- 勾选需要更新的UFT3接口项 -- 点击"全选"选中所有UFT3项 -- 点击"取消"清除UFT3选择 -- 点击"更新UFT3"执行更新 - -### 更新内容 - -- UF20: 接口、标准字段、数据类型、错误编号 -- UFT3: 接口、标准字段、数据类型、表结构 - -## 设置 - -配置工具的运行参数和选项。 - -### 路径配置 - -- 设置Python脚本路径 -- 配置转换工具路径 -- 设置配置文件目录 - -### 显示设置 - -- 调整界面主题 -- 设置字体大小 -- 配置语言选项 - -### 高级设置 - -- 设置自动保存间隔 -- 配置日志级别 -- 设置最大并发任务数 - -## 关于 - -### UFT30 Change Code - -一款强大的编码转换工具,支持批量转码和非LS格式转换。 - -#### 基本信息 - -- **版本**: 1.0.0 -- **作者**: UFT30 Team -- **版权**: Copyright © 2024 -- **联系方式**: support@uft30.com - -#### 功能特性 - -- 支持UF20到UFT3函数格式转换 -- 批量转换处理 -- 非LS格式转换支持 -- 功能查询与快速添加 -- 元数据自动更新 - -## 使用技巧 - -### 高效使用技巧 - -- 在功能查询中找到需要转换的函数,双击可快速添加到转码列表 -- 精确匹配适合已知完整函数名的查询 -- 模糊匹配可以使用部分关键词查找 -- 配置文件可以手动编辑,工具会自动检测变化 -- 批量转换前建议先进行元数据更新 -- 使用Ctrl+F快速搜索帮助内容 - -### 注意事项 - -- 首次使用前请先执行"元数据更新"加载最新配置 -- 转换前建议备份重要文件 -- 确保 uf2touft3/uf2touft3.exe 存在且可执行 -- 配置文件 uf2touft3/cust.json 会自动同步转码列表 -- 大文件转换可能需要较长时间,请耐心等待 diff --git a/resources/help/tips.md b/resources/help/tips.md deleted file mode 100644 index ffe0b21..0000000 --- a/resources/help/tips.md +++ /dev/null @@ -1,18 +0,0 @@ -# 💡 使用技巧 - -## 高效使用技巧 - -- 在功能查询中找到需要转换的函数,双击可快速添加到转码列表 -- 精确匹配适合已知完整函数名的查询 -- 模糊匹配可以使用部分关键词查找 -- 配置文件可以手动编辑,工具会自动检测变化 -- 批量转换前建议先进行元数据更新 -- 使用Ctrl+F快速搜索帮助内容 - -## 注意事项 - -- 首次使用前请先执行"元数据更新"加载最新配置 -- 转换前建议备份重要文件 -- 确保 uf2touft3/uf2touft3.exe 存在且可执行 -- 配置文件 uf2touft3/cust.json 会自动同步转码列表 -- 大文件转换可能需要较长时间,请耐心等待 diff --git a/resources/icons/.gitkeep b/resources/icons/.gitkeep deleted file mode 100644 index f38471a..0000000 --- a/resources/icons/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# 此文件用于保持icons目录在Git仓库中 diff --git a/resources/images/chaxun.png b/resources/images/chaxun.png deleted file mode 100644 index 952dacc0aa2f97f4dc4870ac7dad3a33cd7aaac2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5034 zcmai&`8Sl`AHdP58I&3OZj3SZY(*r^SjG%v$re(!K_W%gA`G%*88c%i+YH&)Y>_Ry ztVLyMLDmq{Mi3!Yv)kj$cGjHXA8XvzC=&y0kT6MEY7*2TCqN@u!n+>L6QYoiREMQh~ z7vyL#Yk(rbg1|h9 z`37%}jQ~xF%_Pg}F+d#bcNgJD!_UFWD0EC^uObVB6zAfPK*uoB$>BwPAPizJ|55|b zYp$t_RAJ+*k_*=#Cej_&%ci1eFR>aK)rvQ5e!G-O`7&t1%&2HBw~0JbXfDS~sMz8&9Vum%aw9J2F43 z%AZNwmhQl@LB>*z4L+G~=g^c=&q;Uigo zy0tpl%M%=V9ul4Y73O^4>=|Dlq54yFU3SWintmF*#)3-cH@%uZH5*(wKeZAz4Amfv z553@sN3H`t*!?oMvq_t4)TT16_ln3Luv>h?WeVFl49ve-pVhV-7!7E*+V-N(e^|e) zj2|&q?QL$}csRYs!P*i_*IwVGrDp>vMqu}1Gd_ePSzRhc4m$WBZ|mq&^B-lq+V#Fk zzs}Fz>rAy+-${Ea9{0LZlRE;up?mYH{;;I@>pVC&U`Z?RcPqpWTZrUx-wSvz=&4XF z#f1+m+~;0DqV=++B)C#RdsR*YVJ!n zBIrX;ILa?C8pD9xVOHhNMmxbF-|Aws?rgU;uZPa>SxQB$oF~Sl^~`RzS1ZV-;8Y)* zS4%}8WdWL~5Vx|pT>GA-GYxB2B3ffFSOn`zJLN*ZU4{Vu7n4|X0&u{aYc zNTM)hh!e9p*te(eRafR56M>bZ=$<+{3hF^PHVt{0-B@00&+lTcS^nE( z^2A^2PUcK>p_>i6ynwM1ODk9OOC3pE$DyGAm}}o1)ixu0631Nj#7+^X4HqJ5S;~-L zKJ>|2@&Grg;(xRw^!ULKRh*Y!wrVTottxBeJg}#JTdP>fOLko0JJGTo*Mvw6wjNR- z#~BWvsup1fHX4CpBD%AbPvtS^mAE;c))%r2++=(}x9?UgXN8a~e!E-mEzGL(6Kb0n zzs@kVsns)0Gi8^1rlkif&+lVegZg;XmTr}%RSTGieQO0r=NUk;Jk~SjR}A`b)kEuz zTh#fT^Hs1$*ezCt*M3l7iZxw=GEbaWlpmzWIgE$^-j7tUVQoJHvEP1e@BV!Y>iMN3 zx_=w8prF@J-ywQsB{RXlB=xReKgzGekcQr#@QMDC3LxmuUcY9QxL8>n>miUT(_I(N?99@+&?uAB?ukw!OfgM{|oT}p4HT7h~Jrsb;6 zaN~Ow=GkX5V57kmb;B@&McybMMrK}S%WFAn@-e$&`?t5P*AxnNU|B6IyekDEC+@P4X#T9CFwVe>9+pkV#?T~@ z2oL{eo+n|`m;m-NOcFFzVLwEb?Se~AlZjT@(!xn^)X*2vUt=7HCAwg-n%gLtn?7oh zU!~DR3ed8yuU=YQsWqKI)ddeS??xF>2IK-wMewViDkQrqn59`eCKCWA`|8H=PzZdS z0+A0~u|-E9>2Hi}a6w-;UzUNFtGauV`J9w5<@|(psh3WE_-VRDAhDo=IkRaTSKKa+ z%l4)=Lidk9v+u){U#G2^#EGuO#vwRsWWn&?ccdNTzuxWECce=~^Y#(Nc7V80Wz0Ei zOYaWPzFvWzmh39!7cdgXYvyzOk}1#ilZxTP_d(me1%)oW_&@|Y2+_x-R|zE{*-1H2 z+$BYUh~)9?1)kaltQv5)Fb{(<)=%_n64XGg6&UWi`b)WZ9c)CH%ph99%LjJFxgK97M@;7li7np6DYl5gIChsOpCtj# z2g8RRyEjH@BKE|W(dzlwbXQMEBoKu$ova$X1lq$*oRx7Tv1NXsIs=OGJCf5~p&{#8 z%Shsxsi-P$*|w{h#@ukAm34zm?IfXGsPnsrA@reZLi*BHoz(A&8$^9}L?qte9*&Wa zO`gr0ZmM2{4^)3IvpwlI$~EZQ1m%FBk@APdIjP4fBZ!1YjUoeSDVjfKj<1>$j@2^V z)$7k?f2>3So^f-?+b4H_o>Co&giaES*gqUwa(Ad?K_%rj|D_hXrg~Y}o9Ez+F?vuD zGeo=WuvnxRL`Q2l-05Gwe1@PObVGzYx(|KS<20saMueKCy*ZH-uWTTxRq*_^U#)Bv z|CCY#M2pJyI+?F$B{i@zCk;Ll3njZi6053UV5vcIdlT!CRL0J1O^FTd6+tBDE+inA zC(~zo^XO>je6dWBs%Q#j45ot0!~hMS?HS!e6E+-)1QFi|)zU36HgdAdKR7Xsxbt_6Q;HLpBXb%x9fk z+IK`*=A4aW%G+P-e~TCdBMm!lK`tz-$lt!xAOS9*C2Nn}s=kyU)@^Zuu(*wSY@o|B zd>}J#$H{b&I%JRfyY)r*!kbwIR>H2Em1Nv8GmMMyi6S4&ztsE%=M}1STh(pp(`P_4LHmRICC2}4Gx#|;)U@IU??xan$8Q8E>R<~tFfMrsc z^V7Nb(VKlPCi8Mq1gnseB))H$&q~VXI?)Y}l^yduuXIUD5q9GO5beAoiEU%wjeAe36{=AYB3MiN5(0{Axa7CJR= z;;>1G;&IE`mqx`bsG=r=_cSyZSv8el-eUY^$euk??w4qVKs71Q$O4W@v!QN?D*x=c zs}|-ZUoWS4f7PDz3;DIZ!qjBNr>zdf*e4@Jtv^(uD2sPl(TN8-OVLwxx4Kz>nrP!a6_}$rUs%38` zly(LG{zFYD4M*IxRwnA3VuIiP_8Y8!iLP`l{uu|mpUlBY479O80AuR&Ppkzs58Jm6 zGVH&hoCuSz*1*4C&tJ7qX^ma*w~!v}v{J}V*MTLmNvc_E9ZueV(DW#k%W(xQxgS5i z@u_dh)OZ6>czzR-$84tE$P% zXLTz!9xa8F^7CkX5=3?s0Re6{xpio@* zZ_$QJtEhD_<8Jm@RodTj(_1MRE1wHn-U;@+DEnSZnqbZ`yCMFET{CT2ul`$1<=SI2 z)p3hwMOJDF*MsbUzuJ+`w%=exVH8GPFHL-@plI&|2rpY|cCm6*@wmFM&!bjVnQLF4 z8(POV28Vz#$pDE3!!_|RF3q&)o$W9)opY2|R`v-SU0LI6Q{_f)&L*EMpl{knmPtRG8Ufq`8?d9%Dt539a5 zPs3$npgfnzE?ZfB7~a)@G-UzVm!{0MdjSr@?wz(j?!jm&-NWuSa-Bx20Wl#|(d7_d zPygQgSvrV3DJ&LH2oJ9auP`pd`90xk(p*`Hf6BvlgX_Cu#>p6YCD~|=|899#3RyRy zcX?`{V7##RV-I>-en_LD_;44YNX5dCr?H-ZtkE6*0Hj+9@5Gmho>G2W>(DmcHpgK^ z(C9=%#$Eaw?T{SA_^e=%J95mzUey6rta%i9_~x*i53Chj7^Ehs;c}D07spq z8G-jqR>Ryxl|5jkDj{vU_SVBfN?!{;?K|dKc87GY!9~@y(6myi-fgFwOC3I2>#Or+ z4;Isx*-hU2RP6UrP1rWebc&doQ+43qdC64%RKYQ01n@)0&A*wNtH~mNoSwH^@rgw~ z|Lo1wksLZLXTH8}J3ZxH?In!y^c9eO6rXG;TN&y;b$^1yO~xZ-DW=4st^9&havXnv zqI~k9KS5lNEi~Kpgdk)*AEiwf3~e#{yXL=NrH6jvZ=blU>2AY&pW8{%`dwlcJPf?a zg+fSt&f|LGC-}H-H|!|Iqj|V;4>)+9G_32Yh3S6CGeH?YNNiNJ5P^` z&XcTQ1Sg0bLUzNyxe;3V!sT2{MqBmd3t#s272oQH5et&!V2hW&@=1={!0EZzK^keg zr&c89=5&^)hn<$OIrJ^5zL1w-+mc&T;&sDFg1;ix?$zU(F-wXm`87b$Bn{ueXM80X zUffqeNPr2|^Ty(cfpWH>I)4%zfdN@JcWC=3GkTBGDkzy+2Bh;4DupduS4T^a ziyI!Zk?tX+g<|x^hqH{zpGwDrNJiEp8lnaa{!p_^31!a(>q59-@kS&LEW-fC8O=-y zu=En6gB)yAW^7%ufr9`~7K2z7-pDrimzA1jj0*v$XfLi2gH(a5pt8%k&Xw{s=xi`4<_Lij>=)c#fts>N`nO$`HY08Kq=wSfjOKC5EmLB4)Ez z8l@Sv+e-6KXL4C(z`f) za5D*VrXD4p48u|a2?#cjATpy8dgii*{(mKEfHo82DBH-;U2%rl2^$r4MKkz0%=H>oG247W9LHb1)gAZ}ub{au`4rhqAzkge7R-zf5)BldwDJGBf_}sl0pS$OMpSvganT{F>AsrzW78Z#*Tt)xB?0rZey!*V|xZ)!g78pxiMe(^G z_HGUliecP4YN%DLRLz*h!Pm}+J1j-NLJ1MJ2e#-zxLW|ZLUSllz@Jm*B@OBre zh@YTWeOEwI8!5r(QbSUOEAd@okWZNmr~+kLnpRr!GyUtY%3~B1fr}a6RS^FC_=%!P z$VbYzW**SMvXO?}wEAhhDL@v2-nnclQ>Y?rukA4!6{iAqk;x2qw3Rk$?5FIx!*z17 z9_O^~H`n-c$se=k5_cYgDOjU2jopvfJIsGCh=H$85k1i+%z<TC|NQgSlS9iTf*Y?g90f>e4wlT^YzW0ixmlp1+ z)p(*DCuq<4TZ+Tm_$gX&@|QdgXA8X#8x02)-nC94%#V88x$D0rFMXWKWPK;vZV(qO z9YMmO>y(KwfG=yo-)Cjn=2{~Gc0pq$SV?7+5#&L1pyX0JMW*|d`Fxkd7)d*|M9gG- zX6B&68`BEgop1fL)_GAL1aVY2@5@1dUD+Yawv3ViAfm((1D)!%Lgs55RPXQ@|Qy|5cTslSiBJF3_rB->loIVU}boSLAJW>~bM+2UHB9EohUjoWp94z@%K8Lcu%GA{uh?=;~LyNmn_K zCjirQWT1Ju9G|cS(GgV%?-1A&J24mUfBW+noFHOSrGdsaS5Kw=Hi<}-3KwwS$9N@7 zcsAjj5CP-t=}B69Jj^K(K*B{^pp7888c9lIrpJ}criM%Bwo8?dDy%*pKGMVDm8jcQo#OZuY=V#6CjU*E5A>8n|E96GlOq4TkerUhs~hWZh_$e3f@K z$^~_Qs4Ot=@5 z!(>Zyu?k&$3AhVt`WM3vSPZXhZ7qRe?T(~_S$FKoGD)j!Ks%1SB;_)wo!tOrxN{g6 zpti9yw!*#)y?mnqV~kE$+;Fls{6!^QJBi2X9gx$?)Rr>+b41u5i z;65e{v)I&}76$#*PaLn9**1aNTD$F(sPeD_l{gWJ+cJjD0#=2uvx_pRB00dH9l0*o&b&2{$z z$zvFqEx%IUqhV`@> zs6fo`hd7+v+`EM~`ls$Le6wqc?NGn#$DMa))2+Xe&$;fYbP{N{+~{ao2(_bZ-mYoSVC#k${K!6=tI3m zyzUBLuw)1UmsJo+It>Y@XaI|ojEwHC=+}{V4Pe1^Tz}hH+q+k$sJ3tcnol`!t|F73 z00X4f164(!w2OpG-voyGE=QZ1Y_sn%q0U;BP#GI^ zh={fasifYvhKu#Nv7mr}sQv@y-3?--dsK~7AJ_w66>J2w>kbZwan@5G3gZ=4IDSx4 zf-VD-sv|pp1u)YAQ$D6%4O~U)<$m-<%6;S8luw|yw%%;m&Ug&!o;h|K6H2%`=@8*_ z7>t$&drM~5F*|>sdWq94tmSZ%e`Q4i&=YAs=e;TA+d}c(CP3VRVES0Z9gu__9Nz8G ziF=T|O^1LE0{e*ExBGhE@BuKnmX(nXNSace*w&5ZJn&{js77l$+QkGqF`{0VAqoMg zb3PNyN_D!`B~p2mW1Pif&4RK4oSJ+Eb>Bafl;Y5-K@p)~=nwDFgVCW?B0D%QTkO%8Zjaj{*ph6m}#f7cJJgIrn zsnFyVJ3K23ZHjMKt7kAekEW5wgnni-6XxWn=L|U%ZM|8G?s62A*bAkd`q7h5 z%0Nq#$5yNN{_9=thlbX9Y`VUY1lY(Ft@3Yo)cnzZGntCyZ z#56!_h1YfB0+sSi0YsNH5vwHnbIYlo7*0fN(irCKSvCdW3HfFXETGmR?mmVR#J~V} z{Nrj_p&<5|8?Fg_Ef|no7*Q-gbN^8Nv#)@WM*xnf;2n!C&lTK%9Yw4b^*j6PQI9zL z+hi!*o7}oPzx*9$TKVa_lY+T=x@!ie3-@c+(3Om{1ZG+VitY3EDxIzPc4;uZr^Pi( z<>i0a5Su+ZXqp6;b#PgN>t%a z&na5;u>#4}I>38TupWvH|97OCM>b%DM}m`PJ&M@(hbGHnB3A8pRz2(aICWPR6eYkh z0as}@b-Ch#2Pj8ARZ++3ABR+POnE{Jytr5)c_OWT{LIAn%9P(DrlT-FcN?dqcAO=v zo04Ub82hy{SyIw3#cyKftWk|BSTh}pGA|3X1!+0!LC9c=uMP#QU|R>Tc9N8{$WTq7 zW9C;-MA@a~hXe}KFeubJfc4q9iYhefQTP2xt)jVej?nh0*N5 z6pfCQIpc%Ca9Ej$?zqr<2Nsk#K%=P5;J_;!R{J@m)RKEtDQ^MT>DvA#@A}}>MFJFY z-dcaC)Fc$o(u;xvvNi+<#JfYnVI}akmyEyRAoj$LgsEH>u=W+b`X}=D?Q4_vw{z{& zEjqJq4;QDilNyu=B+dwN$yPovQ?C?wHvMB^&EYavMtRdaJ!oH*_l730+~Dymx__U8 z{qLSWAR_kvi15$_&eQbT<;pik(VC((@H8ze6V^)Y0fDa940}zl=AJKaS@4}Sw^{7P zx?esWeeo~djTfxE7UIY+&>q0@Ea38m(sohORrOb*;^2>3=TAFm#>LmO2yGBHt1n#z zf2!j>MByf6GE4V0v1JxvVao5BK)#jqw_EwDBLBGMe64=#Y@spp`cQnWE#@v|W(&xgtLej*4{Sz4AfG^3=-Fs7n-wG z2wQo{i{Ev@57;v?qJFw5-LnFF!bKz;(kq+1sq0SP5DU zOsLzDLNDx&A(L>!2v3<#rI`-a*~7sx4A}DA3u!t|x_th+06jj)0c+eYtw4%yHG#2a z$fu|%x$?y)1VBH7>3kt>4xzu;MC!?`RkWC$ajPMKB2Le+<%1WhM21eh5%WIU(tvIy zFR4)#M{x~%|EZ^Z1jq+h*+`ZwEmeAt8XEx=l{IGn?`~#XsF9K#<2?&#SC?Pql6FNG z!B2UIAFlwCM-w=hnY37QB4Bw@b(fRCpO9B3DPZ?REGr6;nRxU2y^wq<`$lN3uGH}u z5ZrAy1kf|k4L9DQW59470VfQtYq-Eq?g#&~+vS|k?j!QUQr2G}fI=Rn2GcCZ!w~T4 z_qPnz>hct*Z8-`!?V2h76(_3c(ud z(#$D+fA;tM(rhpjTs_-alSZTh<@MTxl+{pS^moeP=hkH?ZudD&~`0~l)u>tuq%NV`kj&n3gEWhc2_i{=8Hade|hFjnnf zte)7AYD23$mX@O59f+-w#lvCBDUuKUq*lGAP4k}7yx{aWHTh1xM+C3o+X)@-rNFW| zYv)2nn*ijNJ7t!}LwjaU$CxXXmEBp;cmBP2*dn1sTEM|avc&{ouqHVGpQ0l}H-}ap z7jryV?f&J86WkaZUjD|1<|jMZQUg27GqzAC`+J;`t*q*}SSa+orGgJEZA-f}EnDEQ zuADadtOM}4fB2w~)?;Ykyc?1;95(q?TsUma+bo~>xs{Wsa>V4&`f5y5xhJHR0w*tVp3q(i zvYzQ=(Mlu7_%|N^F)81o(S(pie;q($l!1p3(FY}jrA97;{3WtOvxSBuXCV7`j=TKb ze7`y#K8Ra2>vENg3N5kRs3K+fS~3r$At!iu%QY1QK`YUEt^+dQ!@ zwSRSqYCL$k#-PFpGM<(KlhU=G8SgrOQPS8@{hbqwR))7={rG%w3T&j`N?ufy4_ShZ z{HH$twG_{2VP%k?@laWabQ z9NhX>CVGh$wMkzqf3E~HWzWovP^}N4lCq0Y_5`49|r;QFCcbv~8ijYKEKsUQZMA z#ro2nUJ8?$6lgP*o(KdMxoDDgLZ*PT2sY;*v~pAXrq%?-aNvlwS}87(C(fs?1)W5@ z_KxptVHl7fNhf&my>2=vgc#TnvcGtO!_LiP?Z1Qqf?sIwc6NG3h`4>RtDzs z9_`F~B{_dUW0tLp`uoEQjpnog&_kkorTp&h4<01^zJm9_k2qMU&vu@sfxbNVEm-QR KIw}t$`TqdbL9q9_v6&0ljN=JeSp@-g!ARUqNP%H$b zNRc9-Qlv{L(iD**C0|_Yd;h)fkD0ma%$#%YKC{-od*5hNV_h~DUKTn!IyOB#&W!f# z{da+xX!n;kHTiUOP&z%FriDNKMqZGY-ItJ_lADkmYkibR1p})z2vdqJhh#ePzwJ`| z`i)8DlkB6rF=NSkS00#^jb(Akm5jAy9be7SWzZ9cFiJ`EO{F2TGW=g)&0J9Qx!8`* zvrItb#C%lsP0DX`Ded|_Z^4zV*)O4LD=R_!vp%6Ke(L+7j|BV#qj^sdgiPq2yLaz; zdU%9Fk3^d|f@&%%D$acS^!4jweU*+>Y`^BsN5<&d`;+y;uueZ+U0tPz&gebe38{_L zsi~>7#>U3=i&+B%wQKW~Fr!+Sw2o2kikKtQ*52piG0 zy>2+WA4zm)*|B~IL5aH2u>4kZig=C&eh(p&7m+FV^xu_irC zK=z3fkn}IcMqs_S8}>+EZR@4d)x;ydX?KYQJEm2w_c}4gS+qH>HR!9TJyDoOwh^ z&Jn)kY7n%ql{Ge98`%{=Tw2f~i;}c_eWkfTHZ%46Rth-e0DyDue4V=`O3JnImGuCv z>TSH-7=(4-GH6%K%~`ewvLau=IV^Msh+XO`6I|Eaxf}ocZ~8F}{F13q;5m1I=!)=3 zHYf%smry~+L`dtUfKRG@A8yOkN(bUv5)G6)EIuRMd#Q3V2?v~LxXHtQtVvbsD z&f%LrrTVInF24)=i&NfDL}9%#oRYV%t+8xWO{A;%EVGKG^jSAdZkSInfX3oyaK%bC zVf9n)-G#~wTUq61yTEfLWp24(p7pA#s-TqgT^EOrQ;<2Fpp2+>6LAspo_&gnE1BR} zW!q3wa3F+V7(Ul$RRM=scn)M-AIpP8TO_Wu1DNtOIhm|`523djyXK$c(x!j?V zYVq$F&PvLP!ZKl?gR67AHYme)iMnU!I{F4b*@T^sZjTdcZw=8$5k9&9SIcs1j`!Cj zk2WqIMyLSIZX2Ltxn*jK#}8Qg>!TFhjQ^Ba3(Y5D8JE;&8?gRfe-?@p)u#gEWvsEC z$RcnA2CMrsL5xss_j*TJ!0sfBxA<=$mz`uZb_4v*$^v!n+_~taBp5>1La;RP94Dg) zzzjih_oiR4C7UdllTX;k^@X4&Ks3(T{JlT~g_Mj7^R zK4>fOyOt76#FoGu!`1i3JX9Dvg!e9Hr9P<}MVhG3L+1na!ya*!OE7MVm ziqC<@9fa3WI-DwMsRhEoh-Uo9u({?Z)YI=C145n7F+!SMCF4dEg}nh72oBm@=N$k4*99y+40iAJx7o$^ms&orztR z$X~FMIJy4oem@#Ggy5tSn%95)P#-|f#~;HCH9F0Yx*%g5N9skKl5@Qlj`9RzcU?+? zqrIQs{(8##Zhe8{on5pH&zYskSc#7pNWj9H#q_@_N;*0^Ljq5JKX>3#)Z>sOZcmDi@5M50r{g5+Fy;F`k^9 z$kemt!liyi{?HVcA%H$O%rLI0Z6Kz4{?2~b8`y7Q_eDoDEc!5dy0@k+&*$>T`j60_ z;hWv}0dI;yD6ah?y^g?dHMhO=U)q?Z_#DLtfm61RJztAJJ#$k(su5E7-vs9`+>k`< z6sYd1Eb9Y2orP3=BUF~*l&5)QO}f*R46NtYn(yD}PeTutN!W+jk>{(B-QC?Q#uvWT z$*x_GyjKP5adFB;O3-%0BE+B968pmrr@p43)DuRZ1bnwsogPX@(~1k2o_}J{y~l2= zNFHs%35^Wpb_Svy7*B4~HV3$Qs2hlhiD_>PQT=D_=iNxlO(}}{T7zBT1{{yNFR>Iu z_dDt`jqH@9H(#O|XM{@m)*kWdAC9m(TjfDR z;_anlrUIIzxo9K{UPbR1i9lR|XOFMI%UkYG?-n?npInzYV~0PD=D4AU;`DpXSt|$( z$SfyOF#{&50l_VzgoF2`?>KeCm}9A2B6s*a(8(omJTy?kTn?0Jo69LQF(yia?pj=$ z1!ZQoWq?gS0HT3OVbmgn^Cqs;}_~a7!8%2sf|$fkF{1dLCIxGQdgyS72Tn^+i+b zC6d4o5+U)M`r<(!Xa&EE{KxsfM*5{l{IE3=xQiGDf)~d=H(k`l0?~5{*Kl$|C_>k& zvv}$OjD%hEGeTaA0}t2{y1?~X6?Om|PqDwOZ4b}}5L8u6zbI{GTaD&RqO>Lc`(x$* zJ_&O#6Z&!$3smI0zf5PMy#a-l{_lb4@kXmla>BG3H^Rkp{r}DQNDXN;0LG)_-lAB< z>}g>9Z*piOF4D>lNaKAMdVQb(hSdBN$uE;KczZu#+g%d47#{|46*nxF&l+fO>aVH> zcV&e^mXe_K9)kS*(>z7*Vq&n>5D^_+-R4N*Pcd|VWjVMjA1jn+?1|~WEISAB4tei~ z=FAKL-S&#TPqa+YFOlB=rqfji^4|PuYka6(m<*1CbOE>PL)dL)ByYTh_E3nzpO^CA zq~cI}APi`9ZjP#~))y3gpiygg_j1RfWX)KSBPZG6_HR{g3bHd z@?lCzudDpGn}1VYRidH#sJJj-4;65rsHiBtVxh4t-zq07`_nyxolOqq!za!XV zu=K$3dykb}DJic-j!=IBv=^Ql=r=FUg2efbmyF#VzuE*S{P=8$ZWsJ&(9y&wxB%*i zI7Uo&?MJeSm>e2+Jr`RGm|yjB0**)Q&6IUV8iHD=k-)I#4O?58B9;}wx7}qWLlzQ^ zQ;%JR<}>J?v{1w4eP}!{AjywM4XAL9o?LNc7m|$@2Ffj$<#)fOtP(yfed53AeeI(GR9;clok|17gm1VLu(pJ`IWK)h4ldb!?q=%BCepB>iD^lfY%5#l#ntq<1+g+EnSoTom zJHgS#Q=YB<%>^3sZgNTjkIqt*#E$wUp|&rz(wsthogS)1ZB?x(rxNXtFk#A5hYY6? z+u?_2rggTx$Ct)b7{isNor@M<119R+?6j&Rfp~!YTNkuxYFj~ERs;D9~4wOtG<}|Zu*&6 zd+>qb)-Zb@+uP_(CrAG>b2Y1$|2?fP$_~Vh*P+mfYiWzPTn^yhCg^L}8=6 z&PZAn>`ZYXdYRwdwo-?cdjWH$D>mQD*f1oHzvm0zU5vt}H7+p)CJg_5&;VHyZ+n*N z0FsDnXX>hx#t&df<4=(mJg{;)Ajqb_mEHUJ>L;5AoCb42e5v{U-RX24EqRbbt@MXi zLz-^@y2D*fX)K1IW5h&^Yz_9TwJf^1x$GRKh2oa8W7*7zbk!)r6$#p%qgsYk5p7>2Xp*bxma234xyX!CPv|Z-5 z@*}lYoz(^8X=#ZZ!BsxXR=I(j=HXgk{z+k5B^fPAn=6q~Q7fnzQShS1q{nxg7k3R& zG?mzfO`@K=W@IwbvhG^!EPErDet%wdOZ(`cTlewy?9D{5@dxSB44aR8d{3V~eU)71 zN=viii#g_NvXvcQW91FLE}u1x7ryLzR}R1876$g1x8HDi`{2%fB(cKwwbw|;pv#!@S!2VoB z?IFa_%&tfoI#pa?-#~=vI+r9s2015nk~o?78c%{eYvvP=yEaXc(^}JGSO$ldJEUiO=t(|ZqP() z=wR>?uvdJ5TbyL2)bunbgHz+N;sl$|hSLsw0s{73r^9k(cYS?#U)kE)Ql~4-RS@)o zAgwc9Kk7N^ye>KnoQ`FVr4HV<%~MUi2>Yqo9*wOg`>M7UlgXhfM;e`WnDsonzbP`X zpN8#O*y;~8H8mM)dc2cQ0H3tk@zK$T36gF*!ayiknbs>qlN~eptotrhxdF?-?lj>; z7`+B4_e__>_S#wiI%lw{VOvYYWKxTcoQf-40tvy0jp0$zZO)Jb^NH_!su61NqWcR) zs*-><7>9vYWwad+|B@XE9bC~1I@jgT5ic4 z_iN|}h{D9r5PBJ%U(&Sow;w-#Sow4w@p#O1Mo_IJWe|jiFmW0<@0ppIVG%easI>0A zJ-7Vwg*h#Y0;yEp6FB6bJJGNwv_np&e~iXJxBVYWy=aBiGA%-nul -echo ✓ 源代码已复制 -echo. - -echo [步骤 3/4] 复制配置文件... -if exist "%PYTHON_SRC_DIR%\config.ini" ( - copy /Y "%PYTHON_SRC_DIR%\config.ini" "%BIN_DEST_DIR%\" >nul - echo ✓ 配置文件已复制到 bin 目录 -) -echo. - -echo [步骤 4/4] 复制临时可执行文件(占位符)... -REM 这里可以先放一个简单的说明文件 -echo 请运行 build_package.bat 来生成 uf2touft3_converter.exe > "%BIN_DEST_DIR%\README.txt" -echo. - -echo ======================================== -echo ✓ 初始化完成! -echo. -echo 下一步: -echo 1. 运行 3rd\Python\uf2touft3\build_package.bat -echo 来打包 Python 项目 -echo 2. 编译 Qt 项目,Python 工具会自动 -echo 拷贝到 bin 目录 -echo ======================================== -echo. -pause diff --git a/src/components/elasearchedit.cpp b/src/components/elasearchedit.cpp index 3a41b62..eb237c5 100644 --- a/src/components/elasearchedit.cpp +++ b/src/components/elasearchedit.cpp @@ -3,7 +3,7 @@ #include "ElaIcon.h" ElaSearchEdit::ElaSearchEdit(QWidget* parent) - : QWidget(parent), m_exactMatch(false) + : QWidget(parent) { initUI(); } @@ -46,7 +46,8 @@ void ElaSearchEdit::connectSignals() connect(m_lineEdit, &ElaLineEdit::returnPressed, this, &ElaSearchEdit::returnPressed); connect(m_searchBtn, &ElaToolButton::clicked, this, &ElaSearchEdit::searchClicked); connect(m_exactMatchBtn, &ElaToolButton::clicked, this, [this](bool checked) { - m_exactMatch = checked; + // 真实状态由接收方(FunctionSearchPageBase::m_exactMatch)经信号维护, + // 本组件不再冗余镜像一份只写不读的状态 emit exactMatchChanged(checked); }); } @@ -85,19 +86,10 @@ void ElaSearchEdit::setPlaceholderText(const QString& placeholderText) void ElaSearchEdit::setSearchEditSize(int w, int h) { - // 35 = 搜索按钮宽度; 内部控件与整体尺寸联动 + // 35 = 搜索按钮宽度; 宽度下限保护: w<35 时 w-35 为负尺寸(Qt 忽略并告警, 布局意图失效) + w = qMax(w, 35); + // 内部控件与整体尺寸联动 m_lineEdit->setFixedSize(w - 35, h); m_searchBtn->setFixedSize(35, h); QWidget::setFixedSize(w, h); } - -bool ElaSearchEdit::isExactMatch() const -{ - return m_exactMatch; -} - -void ElaSearchEdit::setExactMatch(bool exact) -{ - m_exactMatch = exact; - m_exactMatchBtn->setChecked(exact); -} diff --git a/src/components/elasearchedit.h b/src/components/elasearchedit.h index 9445cd7..95665ac 100644 --- a/src/components/elasearchedit.h +++ b/src/components/elasearchedit.h @@ -23,9 +23,6 @@ public: // 内部输入框与按钮宽度不联动, 语义易混淆, 故改用显式命名 void setSearchEditSize(int w, int h); - bool isExactMatch() const; - void setExactMatch(bool exact); - signals: void textChanged(const QString& text); void returnPressed(); @@ -41,7 +38,6 @@ private: ElaLineEdit* m_lineEdit; ElaToolButton* m_searchBtn; ElaToolButton* m_exactMatchBtn; - bool m_exactMatch; }; #endif // ELASEARCHEDIT_H diff --git a/src/components/interdialog.cpp b/src/components/interdialog.cpp index 87b026b..9541240 100644 --- a/src/components/interdialog.cpp +++ b/src/components/interdialog.cpp @@ -32,9 +32,10 @@ InterDialog::InterDialog(QWidget* parent) : ElaDialog(parent) defaultButton->hide(); _okButton = new ElaPushButton("确认", _buttonWidget); + // 确认即接受: 确认结果通过 exec()/accepted() 返回值获取(原 okButtonClicked + // 信号全工程无接收者, 属死代码已删除) connect(_okButton, &ElaPushButton::clicked, this, [=] { - emit okButtonClicked(); accept(); }); _okButton->setLightDefaultColor(ElaThemeColor(ElaThemeType::Light, PrimaryNormal)); @@ -53,7 +54,6 @@ InterDialog::InterDialog(QWidget* parent) : ElaDialog(parent) _cancelButton = new ElaPushButton("取消", _buttonWidget); connect(_cancelButton, &ElaPushButton::clicked, this, [=] { - emit cancelButtonClicked(); reject(); }); _cancelButton->setMinimumSize(0, 0); @@ -113,9 +113,4 @@ void InterDialog::setStandardButtons(const QDialogButtonBox::StandardButtons but void InterDialog::addFirstButton(QPushButton* button) const { _btnLayout->insertWidget(1, button); -} - -void InterDialog::addLastButton(QPushButton* button) const -{ - _btnLayout->addWidget(button); } \ No newline at end of file diff --git a/src/components/interdialog.h b/src/components/interdialog.h index a9dbd0b..bf41a11 100644 --- a/src/components/interdialog.h +++ b/src/components/interdialog.h @@ -35,17 +35,7 @@ public: // 头部添加按钮 void addFirstButton(QPushButton* button) const; - // 尾部添加按钮 - void addLastButton(QPushButton* button) const; - -signals: - void okButtonClicked(); - void cancelButtonClicked(); - protected: - // 主题 - ElaThemeType::ThemeMode _themeMode; - // 主布局 QVBoxLayout* _mainLayout{nullptr}; diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 6200c05..eb3e134 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -1,8 +1,5 @@ #include "mainwindow.h" -#include "ElaIcon.h" -#include "ElaText.h" #include "ElaTheme.h" -#include "ElaToolButton.h" #include "utils/uf2configreader.h" #include "utils/uft3configreader.h" #include "utils/configmanager.h" @@ -10,24 +7,12 @@ #include "utils/Constants.h" #include "utils/versionchecker.h" #include "utils/datacache.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include #include #include -#include -#include #include #include -#include +#include #include #include @@ -63,14 +48,12 @@ MainWindow::MainWindow(QWidget *parent) LogManager::instance()->logInfo( QString("[启动计时] loadConfigCache(缓存库打开+校验) 耗时 %1 ms").arg(t.elapsed())); - m_pythonRunner->setRunMode(PythonRunner::UsePackagedExe); - QString exePath = Constants::toolExe(); - QString workDir = Constants::toolDir(); - + m_pythonRunner->setPackagedExePath(exePath); - m_pythonRunner->setWorkingDirectory(workDir); - + // 整体运行超时兜底: 引擎卡死时自动终止, 避免任务无限挂起 + m_pythonRunner->setRunTimeoutMs(Constants::Timings::ConvertRunTimeoutMs); + QFileInfo exeInfo(exePath); if (!exeInfo.exists() || !exeInfo.isFile()) { LogManager::instance()->logError(QString("未找到转换工具: %1").arg(exePath)); @@ -85,14 +68,14 @@ MainWindow::MainWindow(QWidget *parent) connect(this, &ElaWindow::navigationNodeClicked, this, &MainWindow::onNavigationNodeClicked); // 启动后延迟检查版本更新 - QTimer::singleShot(3000, this, &MainWindow::checkForUpdates); + QTimer::singleShot(Constants::Timings::UpdateCheckDelayMs, this, &MainWindow::checkForUpdates); // 启动即异步预热功能查询缓存(singleShot 0: 先让首帧渲染, 再提交线程池, 不阻塞启动): // 否则 uft3 缓存要等用户首次进入查询页才在主线程同步重建(2 万+条可达 3s 卡顿); // uf20 启动期存在性检查虽会触发预热, 此处统一兜底(已就绪/在途时内部幂等跳过) QTimer::singleShot(0, this, [] { - DataCache::instance()->warmupFunctionCacheAsync("uf20"); - DataCache::instance()->warmupFunctionCacheAsync("uft3"); + DataCache::instance()->warmupFunctionCacheAsync(Constants::Cache::Uf20DbName); + DataCache::instance()->warmupFunctionCacheAsync(Constants::Cache::Uft3DbName); }); } @@ -179,6 +162,8 @@ void MainWindow::initContent() markPage("表替换"); addPageNode("函数替换", createFunctionMappingPage(), m_dataMappingKey, ElaIconType::Function); markPage("函数替换"); + addPageNode("不转码功能函数", createNoChangeFullNamePage(), m_dataMappingKey, ElaIconType::CircleStop); + markPage("不转码功能函数"); addPageNode("元数据更新", createMetadataUpdatePage(), ElaIconType::ArrowsRotate); markPage("元数据更新"); @@ -295,6 +280,11 @@ QWidget* MainWindow::createFunctionMappingPage() return new FunctionMappingPage(); } +QWidget* MainWindow::createNoChangeFullNamePage() +{ + return new NoChangeFullNamePage(); +} + void MainWindow::onLsConvertStart() { beginConvert(ConvertKind::LS); @@ -305,11 +295,31 @@ void MainWindow::onNonLsConvertStart() beginConvert(ConvertKind::NonLs); } +ConvertWorkbenchBase* MainWindow::convertPageFor(ConvertKind kind) +{ + if (kind == ConvertKind::NonLs) { + return m_nonLsConvertPage; + } + return m_batchConvertPage; +} + +ConvertWorkbenchBase* MainWindow::otherConvertPageFor(ConvertKind kind) +{ + if (kind == ConvertKind::NonLs) { + return m_batchConvertPage; + } + return m_nonLsConvertPage; +} + void MainWindow::beginConvert(ConvertKind kind) { if (m_pythonRunner->isRunning()) { LogManager::instance()->logWarning("警告: 上一个任务还在运行中..."); QMessageBox::warning(this, "提示", "上一个转换任务正在运行,请等待完成或停止当前任务。"); + // 页面 onStartConvert 已推进进度显示, 失败分支必须收尾, 否则 UI 永久滞留"准备转换" + if (ConvertWorkbenchBase *page = convertPageFor(kind)) { + page->onConvertFailed("上一个转换任务仍在运行"); + } return; } @@ -320,6 +330,10 @@ void MainWindow::beginConvert(ConvertKind kind) QMessageBox::critical(this, "错误", QString("无法启动转换任务!\n\n请确保:\nuf2touft3/uf2touft3.exe 存在,且该目录下包含 config/config.ini\n\n期望路径:\n%1") .arg(exePath)); + // 页面已显示"准备转换", 失败必须收尾恢复 UI 状态 + if (ConvertWorkbenchBase *page = convertPageFor(kind)) { + page->onConvertFailed("转换引擎缺失"); + } return; } @@ -355,6 +369,10 @@ void MainWindow::beginConvert(ConvertKind kind) LogManager::instance()->logError("错误: 无法启动转换进程!"); QMessageBox::warning(this, "失败", "转换进程启动失败,请检查转换引擎程序是否存在、被占用或被安全软件拦截"); + // 页面已显示"开始转换...", 启动失败必须收尾, 否则进度条永久滞留 20% + if (ConvertWorkbenchBase *page = convertPageFor(kind)) { + page->onConvertFailed("转换进程启动失败"); + } return; } // start() 已受理启动流程(同步启动失败已在 start() 内检测并返回 false): @@ -362,28 +380,19 @@ void MainWindow::beginConvert(ConvertKind kind) m_taskActive = true; // 本页进入运行态;另一 Tab 禁止开始,避免并发冲突 - if (kind == ConvertKind::NonLs) { - if (m_nonLsConvertPage) { - m_nonLsConvertPage->onConvertStarted(); - } - if (m_batchConvertPage) { - m_batchConvertPage->setTaskBusy(true); - } - } else { - if (m_batchConvertPage) { - m_batchConvertPage->onConvertStarted(); - } - if (m_nonLsConvertPage) { - m_nonLsConvertPage->setTaskBusy(true); - } + if (ConvertWorkbenchBase *page = convertPageFor(kind)) { + page->onConvertStarted(); + } + if (ConvertWorkbenchBase *other = otherConvertPageFor(kind)) { + other->setTaskBusy(true); } } void MainWindow::onConvertStop() { - // 用宽判定: 启动窗口期(state==Starting, 杀软扫描可达 3s) isRunning() 为 false, - // 原判据导致点"停止转换"静默无效, 任务照常执行且无任何反馈 - if (!m_pythonRunner->isActive()) { + // isRunning() 为宽判定(含 Starting/Stopping): 启动窗口期(杀软扫描可达 3s) + // 窄判定会导致点"停止转换"静默无效, 任务照常执行且无任何反馈 + if (m_pythonRunner->isRunning()) { LogManager::instance()->log("正在停止转换进程..."); m_pythonRunner->stop(); } @@ -400,6 +409,10 @@ void MainWindow::onPythonRunnerFinished(int exitCode, QProcess::ExitStatus exitS } m_taskActive = false; + // 成败判定前冲刷行缓冲: 最后一段输出可能无换行结尾(如 "[1/1] FAIL xxx"), + // 不冲刷会漏计最后的失败数, 导致引擎退出码 0 时误报成功 + flushConvertLineBuffer(); + // 成败判定: 引擎正常退出(退出码 0)且未检测到 FAIL 行才算成功 const bool success = (exitStatus == QProcess::NormalExit && exitCode == 0 && m_failCount == 0); // 失败 UI 文案: 有失败计数时显示失败文件数(信息量更大), 否则为引擎异常退出 @@ -408,30 +421,17 @@ void MainWindow::onPythonRunnerFinished(int exitCode, QProcess::ExitStatus exitS : "转换失败"; // 完成态回到发起任务的 Tab,并解除另一 Tab 的互斥限制 - if (m_activeKind == ConvertKind::NonLs) { - if (m_nonLsConvertPage) { - if (success) { - m_nonLsConvertPage->onConvertFinished(); - } else { - // 失败收尾: 进度条保持当前进度, 状态文字提示"转换失败"(不再误报 100%+完成) - m_nonLsConvertPage->onConvertFailed(failDetail); - } - } - if (m_batchConvertPage) { - m_batchConvertPage->setTaskBusy(false); - } - } else { - if (m_batchConvertPage) { - if (success) { - m_batchConvertPage->onConvertFinished(); - } else { - m_batchConvertPage->onConvertFailed(failDetail); - } - } - if (m_nonLsConvertPage) { - m_nonLsConvertPage->setTaskBusy(false); + if (ConvertWorkbenchBase *page = convertPageFor(m_activeKind)) { + if (success) { + page->onConvertFinished(); + } else { + // 失败收尾: 进度条保持当前进度, 状态文字提示"转换失败"(不再误报 100%+完成) + page->onConvertFailed(failDetail); } } + if (ConvertWorkbenchBase *other = otherConvertPageFor(m_activeKind)) { + other->setTaskBusy(false); + } LogManager::instance()->log("========================================"); if (success) { @@ -472,9 +472,38 @@ void MainWindow::onPythonRunnerOutput(const QString &output) LogManager::instance()->log(output); - const QString text = output.trimmed(); + // PythonRunner 按任意字节边界发射, 一个 chunk 可能含多行或半行: + // 维护行缓冲按 '\n' 重组, 只有完整行才参与正则解析, + // 否则 "[1/1] FAIL xxx" 被切到两个 chunk 时会漏计失败数导致误报成功 + m_outputLineBuf += output; + int start = 0; + for (int nl = m_outputLineBuf.indexOf(QLatin1Char('\n')); + nl >= 0; nl = m_outputLineBuf.indexOf(QLatin1Char('\n'), start)) { + const QString line = m_outputLineBuf.mid(start, nl - start).trimmed(); + if (!line.isEmpty()) { + processConvertLine(line); + } + start = nl + 1; + } + // 残行(块尾无换行)保留在缓冲, 与下一块拼接后重组 + m_outputLineBuf = m_outputLineBuf.mid(start); +} - // 计算本次输出对应的目标进度;-1 表示本次输出不含可识别的进度标记 +void MainWindow::flushConvertLineBuffer() +{ + if (m_outputLineBuf.trimmed().isEmpty()) { + m_outputLineBuf.clear(); + return; + } + // 日志已在 onPythonRunnerOutput 到达时写盘, 此处仅解析不重复记录 + const QString lastLine = m_outputLineBuf.trimmed(); + m_outputLineBuf.clear(); + processConvertLine(lastLine); +} + +void MainWindow::processConvertLine(const QString &line) +{ + // 计算本行对应的目标进度;-1 表示本行不含可识别的进度标记 int targetProgress = -1; QString status; @@ -483,7 +512,7 @@ void MainWindow::onPythonRunnerOutput(const QString &output) // 收尾时按 m_failCount 判定成败, 否则会误报"转换完成" int failHits = 0; static const QRegularExpression failRegExp(R"(\[\s*\d+/\d+\]\s+FAIL\s+\S+)"); - auto fit = failRegExp.globalMatch(text); + auto fit = failRegExp.globalMatch(line); while (fit.hasNext()) { fit.next(); ++failHits; @@ -493,7 +522,7 @@ void MainWindow::onPythonRunnerOutput(const QString &output) // 引擎失败汇总行(按需转码模式): "非LS批量转码完成: X/Y 通过, Z 失败", 计数取更权威者 static const QRegularExpression summaryRegExp( R"(批量转码完成:\s*\d+/\d+\s*通过,\s*(\d+)\s*失败)"); - QRegularExpressionMatch sm = summaryRegExp.match(text); + QRegularExpressionMatch sm = summaryRegExp.match(line); if (sm.hasMatch()) { const int failed = sm.captured(1).toInt(); if (failed > m_failCount) { @@ -504,7 +533,7 @@ void MainWindow::onPythonRunnerOutput(const QString &output) // 1) 优先解析工具显式输出的 progress:xx / progress=xx static const QRegularExpression progressRegExp( R"(progress\s*[:=]\s*(\d+))", QRegularExpression::CaseInsensitiveOption); - QRegularExpressionMatch pm = progressRegExp.match(text); + QRegularExpressionMatch pm = progressRegExp.match(line); if (pm.hasMatch()) { targetProgress = pm.captured(1).toInt(); status = QString("转换中 %1%").arg(targetProgress); @@ -514,11 +543,7 @@ void MainWindow::onPythonRunnerOutput(const QString &output) // FAIL 行同样含 [n/m], 已前置检测, 此处跳过避免被误当翻译进度 if (failHits == 0) { static const QRegularExpression stepRegExp(R"(\[\s*(\d+)\s*/\s*(\d+)\s*\])"); - QRegularExpressionMatch stepMatch; - auto it = stepRegExp.globalMatch(text); - while (it.hasNext()) { - stepMatch = it.next(); // 取本段输出里最后一个 [n/m] - } + QRegularExpressionMatch stepMatch = stepRegExp.match(line); if (stepMatch.hasMatch()) { int cur = stepMatch.captured(1).toInt(); int tot = stepMatch.captured(2).toInt(); @@ -546,7 +571,7 @@ void MainWindow::onPythonRunnerOutput(const QString &output) {"转码完成", 98, "转码完成"}, }; for (const auto &m : markers) { - if (text.contains(QString::fromUtf8(m.key))) { + if (line.contains(QString::fromUtf8(m.key))) { if (m.prog > targetProgress) { targetProgress = m.prog; status = QString::fromUtf8(m.label); diff --git a/src/mainwindow/mainwindow.h b/src/mainwindow/mainwindow.h index ebacc20..ee24213 100644 --- a/src/mainwindow/mainwindow.h +++ b/src/mainwindow/mainwindow.h @@ -2,7 +2,6 @@ #define MAINWINDOW_H #include "ElaWindow.h" -#include "ElaToolButton.h" #include #include "src/pythonrunner/PythonRunner.h" #include "src/pages/businessconvert/businessconvertpage.h" @@ -14,9 +13,10 @@ #include "src/pages/about/aboutdialog.h" #include "src/pages/settings/settingspage.h" -#include "src/pages/metadatupdate/metadatupdatepage.h" +#include "src/pages/metadataupdate/metadataupdatepage.h" #include "src/pages/datamapping/tablemappingpage.h" #include "src/pages/datamapping/functionmappingpage.h" +#include "src/pages/datamapping/nochangefullnamepage.h" #include "src/utils/version.h" class VersionChecker; // 前置声明: 成员指针无需完整类型 @@ -41,6 +41,7 @@ private: QWidget* createMetadataUpdatePage(); QWidget* createTableMappingPage(); QWidget* createFunctionMappingPage(); + QWidget* createNoChangeFullNamePage(); void loadConfigCache(); @@ -51,11 +52,18 @@ private: void onNonLsConvertStart(); void onConvertStop(); void beginConvert(ConvertKind kind); + // 发起/收尾两处镜像共用的互斥访问器: 发起页进入运行态, 另一 Tab 施加/解除互斥 + ConvertWorkbenchBase* convertPageFor(ConvertKind kind); + ConvertWorkbenchBase* otherConvertPageFor(ConvertKind kind); void onPythonRunnerFinished(int exitCode, QProcess::ExitStatus exitStatus); // 进程级错误(如 exe 无法启动): start() 已异步化, 启动失败必须由此收尾恢复 UI void onPythonRunnerProcessError(QProcess::ProcessError error); void onPythonRunnerOutput(const QString &output); void onPythonRunnerError(const QString &error); + // 引擎输出单行解析(FAIL 计数/失败汇总/进度标记), 由 onPythonRunnerOutput 按行拆分后调用 + void processConvertLine(const QString &line); + // 收尾前冲刷行缓冲: 最后一段输出可能无换行结尾(如 FAIL 行), 不冲刷会漏计失败数 + void flushConvertLineBuffer(); void checkForUpdates(); void onNewVersionFound(const QString &latestVersion, const QString &releaseNotes); @@ -74,6 +82,7 @@ private: int m_lastProgress; // 已上报的最大进度,保证进度单调递增,避免进度条来回跳动 int m_failCount = 0; // 从引擎输出识别到的失败文件数(引擎个别文件 FAIL 时退出码仍为 0) bool m_taskActive; // 是否有转换任务在途: 幂等收尾守卫(见 onPythonRunnerFinished) + QString m_outputLineBuf; // 引擎输出行缓冲: chunk 按任意字节边界发射, 半行留待与下一块拼接 QString m_helpKey; QString m_aboutKey; QString m_settingKey; diff --git a/src/metadataupdate/interface_scanner.h b/src/metadataupdate/common/interface_scanner.h similarity index 68% rename from src/metadataupdate/interface_scanner.h rename to src/metadataupdate/common/interface_scanner.h index 66b275b..2fc5606 100644 --- a/src/metadataupdate/interface_scanner.h +++ b/src/metadataupdate/common/interface_scanner.h @@ -5,6 +5,7 @@ #include #include #include +#include #include // ============================================================================ @@ -36,8 +37,8 @@ struct InterfaceInfo { QString id; QString sysStatus; QString description; - QString moudle; - QString moudleEName; + QString module; // 注意: 落库 JSON 键名为历史拼写 "moudle", 保持不变以兼容已有数据 + QString moduleEName; // 落库 JSON 键名 "moudleEName", 同上 QString serverType; bool returnResultSet = false; bool needTransMonitor = false; @@ -49,29 +50,26 @@ struct InterfaceInfo { QMap internalFields; }; -// 统一文件条目(合并原 Uf2FileEntry / Uft3FileEntry) -struct FileEntry { - QString path; - qint64 mtime = 0; - QString serverType; // UF2 使用(trade / acct / dbsett) - bool isTrans = false; // UFT3 使用(是否交易码) -}; - // 统一扫描结果(UF2 无独立交易码子集,transCodeMap 为空) struct ScanResult { QMap resultMap; QMap transCodeMap; }; -// 扫描/组装上下文:承载各业务版本差异化的参数,避免接口方法签名分裂 +// 扫描/组装上下文:承载各业务版本差异化的参数,避免接口方法签名分裂。 +// 文件过滤规则按用途拆为具名字段(替代原 fileFilterDict["containNameList"] 的双语义: +// UF2 当路径子串黑名单、UFT3 当扩展名白名单, 同名键语义相反极易误用): +// - completeNames: 完整文件名黑名单, 文件名精确命中即过滤(UF2/UFT3 共用) +// - excludePathPatterns: 路径子串黑名单, 路径含任一子串即过滤(UF2 用) +// - allowedExtensions: 扩展名白名单, 后缀不在名单内即过滤(UFT3 必配; UF2 可选, 为空退回全量解析) struct ScanContext { - QMap fileFilterDict; + QStringList completeNames; + QStringList excludePathPatterns; // UF2 专用: 路径子串黑名单 + QStringList allowedExtensions; // UFT3 必配 / UF2 可选: 扩展名白名单 QString ufAcct20ProjectDir; // UF2 专用 QString uftDbSett; // UF2 专用 QJsonObject custJson; // UF2 专用 QString moduleGenerationCheck; // UFT3 专用 - QString serverType; // 增量组装用(UF2) - QMap moduleCodeMap; // 增量组装用(UF2) }; // 接口扫描器抽象接口(策略) @@ -82,22 +80,14 @@ public: // 设置外部取消标志(指向 MetadataProcessor 的原子标志,生命周期由调用方保证) void setCancelFlag(std::atomic_bool* flag) { m_cancelFlag = flag; } - // 解析单个接口文件首行属性,得到接口信息 - virtual InterfaceInfo loadInterface(const QString& filePath) = 0; + // 解析单个接口文件首行属性,得到接口信息; + // 文件不存在/无法打开时返回 std::nullopt(错误日志已落), 调用方须跳过该文件, + // 严禁把空 InterfaceInfo 当解析结果入库 + virtual std::optional loadInterface(const QString& filePath) = 0; // 递归扫描目录,返回全部接口信息;UFT3 另返回交易码子集 virtual ScanResult scanDir(const QString& dirPath, const ScanContext& ctx) = 0; - // 单文件重新组装(增量比较用) - virtual InterfaceInfo assemble(const QString& filePath, const ScanContext& ctx) = 0; - - // 仅收集文件路径与修改时间,用于轻量增量缓存 - virtual QList collectFiles(const QString& dirPath, const ScanContext& ctx) = 0; - - // 构建模块编码映射(UF2 专用;UFT3 默认返回空) - virtual QMap buildModuleCodeMap(const QString& /*dirPath*/, const ScanContext& /*ctx*/) - { return {}; } - protected: // 递归扫描循环中协作式取消检查点 bool isCancelRequested() const { return m_cancelFlag && m_cancelFlag->load(std::memory_order_relaxed); } diff --git a/src/metadataupdate/filedb.cpp b/src/metadataupdate/filedb.cpp deleted file mode 100644 index 1365d85..0000000 --- a/src/metadataupdate/filedb.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "filedb.h" - -FileDB::FileDB(QObject *parent) : QObject(parent) -{ -} - -FileDB* FileDB::instance() -{ - // 魔法静态(C++11 保证首次执行线程安全) - static FileDB* inst = new FileDB(); - return inst; -} \ No newline at end of file diff --git a/src/metadataupdate/filedb.h b/src/metadataupdate/filedb.h deleted file mode 100644 index 1daf855..0000000 --- a/src/metadataupdate/filedb.h +++ /dev/null @@ -1,17 +0,0 @@ -#ifndef FILEDB_H -#define FILEDB_H - -#include -#include - -class FileDB : public QObject -{ - Q_OBJECT -public: - static FileDB* instance(); - -private: - explicit FileDB(QObject *parent = nullptr); -}; - -#endif // FILEDB_H \ No newline at end of file diff --git a/src/metadataupdate/metadataprocessor.cpp b/src/metadataupdate/metadataprocessor.cpp index 6806cd8..b78fad5 100644 --- a/src/metadataupdate/metadataprocessor.cpp +++ b/src/metadataupdate/metadataprocessor.cpp @@ -1,18 +1,17 @@ #include "metadataprocessor.h" #include "utils/Constants.h" -#include "uf2interface.h" -#include "uft3interface.h" -#include "filedb.h" -#include "uf2stdfields.h" -#include "uf2datatype.h" -#include "uf2errnumber.h" -#include "uf2constants.h" -#include "uf2menuandfunction.h" -#include "uf2module.h" -#include "uf2table.h" -#include "uft3stdfields.h" -#include "uft3datatype.h" -#include "uft3table.h" +#include "uf20/uf2interface.h" +#include "uft30/uft3interface.h" +#include "uf20/uf2stdfields.h" +#include "uf20/uf2datatype.h" +#include "uf20/uf2errnumber.h" +#include "uf20/uf2constants.h" +#include "uf20/uf2menuandfunction.h" +#include "uf20/uf2module.h" +#include "uf20/uf2table.h" +#include "uft30/uft3stdfields.h" +#include "uft30/uft3datatype.h" +#include "uft30/uft3table.h" #include "utils/logmanager.h" #include "utils/configmanager.h" #include "utils/datacache.h" @@ -24,6 +23,48 @@ #include #include +namespace { +// QMap -> QJsonObject: 单次迭代器遍历, 避免 keys()+operator[] 的"建列表+双查找"开销。 +// 落库前逐行转换是各 process* 的公共热路径(数千条 × 3-7 属性, 后台线程)。 +QJsonObject mapToJson(const QMap& map) +{ + QJsonObject obj; + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + obj.insert(it.key(), it.value()); + } + return obj; +} + +// runParseAndSave 公共行转换: 简单条目整行序列化为 JSON。 +// 收敛各 processXxx 处逐字重复的 convert lambda(8 处), 统一走此具名函数。 +QString simpleEntryToJson(const QString&, const QMap& map) +{ + return QJsonDocument(mapToJson(map)).toJson(QJsonDocument::Compact); +} + +// 源目录 mtime 比较容差(秒): 文件系统时间戳精度有限, 严格相等比较会把浮点舍入 +// 误判为"源已变更", 与 Python 参考实现一致放宽 0.5 秒 +constexpr double kMtimeToleranceSec = 0.5; + +// 字段映射表 -> JSON 行对象: 按指定 FieldInfo 成员序列拼装每个字段行。 +// 收敛 UFT3(input/output/internal)与 UF2(input/output/variable)共 6 段逐字重复的 +// 手工赋值循环, 字段清单显式留在各调用点, 与落库 JSON 结构一一对应可对照 +QJsonObject fieldsToJson(const QMap& fields, + const QList>& members) +{ + QJsonObject out; + for (auto it = fields.constBegin(); it != fields.constEnd(); ++it) { + const FieldInfo& field = it.value(); + QJsonObject fieldJson; + for (const auto& m : members) { + fieldJson[QLatin1String(m.second)] = field.*m.first; + } + out[it.key()] = fieldJson; + } + return out; +} +} // namespace + MetadataProcessor::MetadataProcessor(QObject *parent) : QObject(parent) { } @@ -33,68 +74,49 @@ void MetadataProcessor::setForceFull(bool force) m_forceFull = force; } -void MetadataProcessor::initConfig(const QString& basePath) +void MetadataProcessor::initConfig() { - m_basePath = basePath; - m_uf2touft3Path = Constants::toolDir(); - LogManager::instance()->log("========== 初始化配置 =========="); - - ConfigManager* configManager = ConfigManager::instance(); - - m_uf20Path = configManager->getValue("projectPath", "uf20"); - m_ufAcct20Path = configManager->getValue("projectPath", "ufAcct20"); - m_uftDbSettPath = configManager->getValue("projectPath", "UftdbSett"); - m_uft30PubPath = configManager->getValue("projectPath", "uft30pub"); - m_uft30CbptransPath = configManager->getValue("projectPath", "uft30cbptrans"); - m_uft30Path = configManager->getValue("projectPath", "uft30"); - - m_supportUftdbSett = configManager->getValue("testConfig", "supportUftdbSett"); - m_moduleGenerationCheck = configManager->getValue("testConfig", "moduleGenerationCheck"); - - LogManager::instance()->log(QString("UF2TOUFT3路径: %1").arg(m_uf2touft3Path)); + + loadConfigPaths(); + LogManager::instance()->log(QString("UF20路径: %1").arg(m_uf20Path)); LogManager::instance()->log(QString("UF20会计路径: %1").arg(m_ufAcct20Path)); LogManager::instance()->log(QString("UFT数据库设置路径: %1").arg(m_uftDbSettPath)); LogManager::instance()->log(QString("UFT30公共路径: %1").arg(m_uft30PubPath)); - LogManager::instance()->log(QString("UFT30交易路径: %1").arg(m_uft30CbptransPath)); LogManager::instance()->log(QString("UFT30路径: %1").arg(m_uft30Path)); } +// initConfig/reloadPaths 共用的路径加载: 6 个工程路径 + 2 个功能开关统一从此读取, +// 两处清单必须同源, 避免逐字重复后只改其一导致初始化与重载读到不同路径。 +void MetadataProcessor::loadConfigPaths() +{ + ConfigManager* configManager = ConfigManager::instance(); + + m_uf20Path = configManager->getValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUf20); + m_ufAcct20Path = configManager->getValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUfAcct20); + m_uftDbSettPath = configManager->getValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUftdbSett); + m_uft30PubPath = configManager->getValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30Pub); + m_uft30Path = configManager->getValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30); + + m_supportUftdbSett = configManager->getValue(Constants::ConfigKeys::SectionTestConfig, Constants::ConfigKeys::SupportUftdbSett); + m_moduleGenerationCheck = configManager->getValue(Constants::ConfigKeys::SectionTestConfig, Constants::ConfigKeys::ModuleGenerationCheck); +} + void MetadataProcessor::reloadPaths() { LogManager::instance()->log("========== 重新加载配置路径 =========="); - ConfigManager* configManager = ConfigManager::instance(); - - m_uf20Path = configManager->getValue("projectPath", "uf20"); - m_ufAcct20Path = configManager->getValue("projectPath", "ufAcct20"); - m_uftDbSettPath = configManager->getValue("projectPath", "UftdbSett"); - m_uft30PubPath = configManager->getValue("projectPath", "uft30pub"); - m_uft30CbptransPath = configManager->getValue("projectPath", "uft30cbptrans"); - m_uft30Path = configManager->getValue("projectPath", "uft30"); - - m_supportUftdbSett = configManager->getValue("testConfig", "supportUftdbSett"); - m_moduleGenerationCheck = configManager->getValue("testConfig", "moduleGenerationCheck"); + loadConfigPaths(); LogManager::instance()->log(QString("UF20路径: %1").arg(m_uf20Path)); LogManager::instance()->log(QString("UFT30路径: %1").arg(m_uft30Path)); } - - -bool MetadataProcessor::isValidUtf8(const QByteArray& data) -{ - QString test = QString::fromUtf8(data); - return test.toUtf8() == data; -} - - - // 元数据解析结果统一落双库(cache/uf20_cache.db / cache/uft3_cache.db), -// 不再生成任何中间 JSON 文件, 详见 saveRowsFromJson / DataCache::saveDataset。 +// 不再生成任何中间 JSON 文件, 详见 saveRowsToDb / DataCache::saveDataset。 -QJsonObject MetadataProcessor::interfaceInfoToUFT3Json(const InterfaceInfo& info) +QJsonObject MetadataProcessor::interfaceInfoToUFT3Row(const InterfaceInfo& info) { QJsonObject interfaceJson; interfaceJson["cname"] = info.cname; @@ -106,80 +128,44 @@ QJsonObject MetadataProcessor::interfaceInfoToUFT3Json(const InterfaceInfo& info interfaceJson["id"] = info.id; interfaceJson["sysStatus"] = info.sysStatus; interfaceJson["description"] = info.description; - interfaceJson["moudle"] = info.moudle; + interfaceJson["moudle"] = info.module; // JSON 键名保留历史拼写 "moudle" - QJsonObject inputFieldsJson; - for (const QString& fieldName : info.inputFields.keys()) { - const FieldInfo& field = info.inputFields[fieldName]; - QJsonObject fieldJson; - fieldJson["name"] = field.name; - fieldJson["flag"] = field.flag; - fieldJson["desc"] = field.desc; - fieldJson["uuid"] = field.uuid; - inputFieldsJson[fieldName] = fieldJson; - } - interfaceJson["inputFileds"] = inputFieldsJson; + interfaceJson["inputFileds"] = fieldsToJson(info.inputFields, + {{&FieldInfo::name, "name"}, {&FieldInfo::flag, "flag"}, {&FieldInfo::desc, "desc"}, + {&FieldInfo::uuid, "uuid"}}); - QJsonObject outputFieldsJson; - for (const QString& fieldName : info.outputFields.keys()) { - const FieldInfo& field = info.outputFields[fieldName]; - QJsonObject fieldJson; - fieldJson["name"] = field.name; - fieldJson["flag"] = field.flag; - fieldJson["desc"] = field.desc; - fieldJson["paramType"] = field.paramType; - fieldJson["uuid"] = field.uuid; - outputFieldsJson[fieldName] = fieldJson; - } - interfaceJson["outputFileds"] = outputFieldsJson; + interfaceJson["outputFileds"] = fieldsToJson(info.outputFields, + {{&FieldInfo::name, "name"}, {&FieldInfo::flag, "flag"}, {&FieldInfo::desc, "desc"}, + {&FieldInfo::paramType, "paramType"}, {&FieldInfo::uuid, "uuid"}}); - QJsonObject internalFieldsJson; - for (const QString& fieldName : info.internalFields.keys()) { - const FieldInfo& field = info.internalFields[fieldName]; - QJsonObject fieldJson; - fieldJson["name"] = field.name; - fieldJson["type"] = field.type; - fieldJson["desc"] = field.desc; - fieldJson["paramType"] = field.paramType; - fieldJson["uuid"] = field.uuid; - internalFieldsJson[fieldName] = fieldJson; - } - interfaceJson["internalFileds"] = internalFieldsJson; + interfaceJson["internalFileds"] = fieldsToJson(info.internalFields, + {{&FieldInfo::name, "name"}, {&FieldInfo::type, "type"}, {&FieldInfo::desc, "desc"}, + {&FieldInfo::paramType, "paramType"}, {&FieldInfo::uuid, "uuid"}}); return interfaceJson; } -QJsonObject MetadataProcessor::readJson(const QString& filePath) +std::optional MetadataProcessor::readJson(const QString& filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { LogManager::instance()->logError(QString("无法打开文件: %1").arg(filePath)); - return QJsonObject(); + return std::nullopt; } - + QByteArray data = file.readAll(); QJsonDocument doc = QJsonDocument::fromJson(data); file.close(); - + if (doc.isNull()) { LogManager::instance()->logError(QString("无效的 JSON 文件: %1").arg(filePath)); - return QJsonObject(); + return std::nullopt; } - + return doc.object(); } -QString MetadataProcessor::getBasePath() const -{ - return m_basePath; -} - -void MetadataProcessor::setBasePath(const QString& path) -{ - m_basePath = path; -} - -QJsonObject MetadataProcessor::interfaceInfoToUF2Json(const InterfaceInfo& info) +QJsonObject MetadataProcessor::interfaceInfoToUF2Row(const InterfaceInfo& info) { QJsonObject interfaceJson; interfaceJson["cname"] = info.cname; @@ -188,46 +174,22 @@ QJsonObject MetadataProcessor::interfaceInfoToUF2Json(const InterfaceInfo& info) interfaceJson["flag"] = info.flag; interfaceJson["path"] = info.path; interfaceJson["code"] = info.code; - interfaceJson["moudle"] = info.moudle; - interfaceJson["moudleEName"] = info.moudleEName; + interfaceJson["moudle"] = info.module; // JSON 键名保留历史拼写 "moudle" + interfaceJson["moudleEName"] = info.moduleEName; interfaceJson["serverType"] = info.serverType; interfaceJson["returnResultSet"] = info.returnResultSet; interfaceJson["needTransMonitor"] = info.needTransMonitor; interfaceJson["checkLicence"] = info.checkLicence; - QJsonObject inputFieldsJson; - for (const QString& fieldName : info.inputFields.keys()) { - const FieldInfo& field = info.inputFields[fieldName]; - QJsonObject fieldJson; - fieldJson["name"] = field.name; - fieldJson["flag"] = field.flag; - fieldJson["desc"] = field.desc; - inputFieldsJson[fieldName] = fieldJson; - } - interfaceJson["inputFileds"] = inputFieldsJson; + interfaceJson["inputFileds"] = fieldsToJson(info.inputFields, + {{&FieldInfo::name, "name"}, {&FieldInfo::flag, "flag"}, {&FieldInfo::desc, "desc"}}); - QJsonObject outputFieldsJson; - for (const QString& fieldName : info.outputFields.keys()) { - const FieldInfo& field = info.outputFields[fieldName]; - QJsonObject fieldJson; - fieldJson["name"] = field.name; - fieldJson["flag"] = field.flag; - fieldJson["desc"] = field.desc; - outputFieldsJson[fieldName] = fieldJson; - } - interfaceJson["outputFileds"] = outputFieldsJson; + interfaceJson["outputFileds"] = fieldsToJson(info.outputFields, + {{&FieldInfo::name, "name"}, {&FieldInfo::flag, "flag"}, {&FieldInfo::desc, "desc"}}); - QJsonObject variableFieldsJson; - for (const QString& fieldName : info.variableFields.keys()) { - const FieldInfo& field = info.variableFields[fieldName]; - QJsonObject fieldJson; - fieldJson["name"] = field.name; - fieldJson["cname"] = field.cname; - fieldJson["hsType"] = field.hsType; - fieldJson["desc"] = field.desc; - variableFieldsJson[fieldName] = fieldJson; - } - interfaceJson["variableFileds"] = variableFieldsJson; + interfaceJson["variableFileds"] = fieldsToJson(info.variableFields, + {{&FieldInfo::name, "name"}, {&FieldInfo::cname, "cname"}, {&FieldInfo::hsType, "hsType"}, + {&FieldInfo::desc, "desc"}}); return interfaceJson; } @@ -276,16 +238,39 @@ double dirMtime(const QString& path) } } -// 将缓存库 (name -> data JSON 字符串) 行集还原为 QJsonObject(key -> 子对象/子数组) -// srcMtime: 源目录最新修改时间(多源时调用方相加), 写入 _meta.src_mtime 供缓存失效判断。 +// dirMtime 结果的单运行缓存包装: 命中缓存免重复全树遍历(详见头文件注释) +double MetadataProcessor::dirMtimeCached(const QString& path) +{ + const auto it = m_dirMtimeCache.constFind(path); + if (it != m_dirMtimeCache.constEnd()) { + return it.value(); + } + const double m = dirMtime(path); + m_dirMtimeCache.insert(path, m); + return m; +} -bool MetadataProcessor::saveRowsFromJson(const QString& db, const QString& table, - const QJsonObject& resultJson, double srcMtime) +// 将内存中解析得到的 QJsonObject(key -> 子对象) 序列化后落库(内存 -> 缓存库方向): +// name 列 = key, data 列 = 子对象 JSON 紧凑字符串, 交 DataCache::saveDataset 写入。 +// srcMtime: 源目录最新修改时间(多源时调用方相加), 写入 _meta.src_mtime 供缓存失效判断; +// srcMtimes 非空时逐目录 mtime 数组一并写入 _meta.src_mtimes。 + +bool MetadataProcessor::saveRowsToDb(const QString& db, const QString& table, + const QJsonObject& resultJson, double srcMtime, + const QJsonArray& srcMtimes) { QMap rows; for (auto it = resultJson.begin(); it != resultJson.end(); ++it) { - QJsonObject val = it.value().toObject(); - rows[it.key()] = QString::fromUtf8(QJsonDocument(val).toJson(QJsonDocument::Compact)); + const QJsonValue val = it.value(); + if (val.isString()) { + // simpleEntryToJson 等已把行序列化为内嵌 JSON 文本(QJsonValue 承载 String): + // 直接取字符串落库。不可走 toObject()——String 转 Object 得到空对象, + // data 列会全量落成 "{}"(menu_func/错误号/常量等所有 simpleEntry 表均受影响) + rows[it.key()] = val.toString(); + } else { + rows[it.key()] = QString::fromUtf8( + QJsonDocument(val.toObject()).toJson(QJsonDocument::Compact)); + } } if (rows.isEmpty()) { LogManager::instance()->logWarning( @@ -299,28 +284,36 @@ bool MetadataProcessor::saveRowsFromJson(const QString& db, const QString& table // 自动创建 cache 目录、DB 文件与数据表。 LogManager::instance()->logInfo(QString("元数据落库 %1.%2 共 %3 行, src_mtime=%4") .arg(db, table).arg(rows.size()).arg(srcMtime, 0, 'f', 3)); - if (!DataCache::instance()->saveDataset(db, table, rows, srcMtime)) { + if (!DataCache::instance()->saveDataset(db, table, rows, srcMtime, srcMtimes, &m_cancelRequested)) { LogManager::instance()->logError(QString("元数据落库失败: %1.%2").arg(db, table)); return false; } return true; } -bool MetadataProcessor::processUf2Interfaces(const QString& basePath) +bool MetadataProcessor::processUf2Interfaces() { LogManager::instance()->log("========== 开始处理 UF2.0 接口 =========="); - LogManager::instance()->log(QString("基础路径(basePath): %1").arg(basePath)); - - QMap filterDict; - filterDict["completeNameList"] = {"module.xml", ".project", "冲突检测.wlua"}; - filterDict["containNameList"] = {".vbusiness", "/公共资源/", "/数据库/", "/脚本/", "/通用数据/", "/.svn/"}; + // 实际扫描源 = m_uf20Path(initConfig 配置), 日志如实记录供核对 + LogManager::instance()->log(QString("扫描源目录: %1").arg(m_uf20Path)); ScanContext ctx; - ctx.fileFilterDict = filterDict; + ctx.completeNames = {Constants::MetaPaths::ModuleXml, Constants::MetaPaths::Uf2ProjectFile, Constants::MetaPaths::ConflictWlua}; + ctx.excludePathPatterns = {".vbusiness", "/公共资源/", "/数据库/", "/脚本/", "/通用数据/", "/.svn/"}; + // UF2 接口扩展名白名单: 接口文件内容为 XML 但扩展名为 *_design(实测这些后缀均含 + // 元素), .table_design/.view_design/.cdata/.xml 等非接口文件直接跳过 + ctx.allowedExtensions = {".aservice_design", ".service_design", ".function_design", + ".afunction_design", ".procedure_design"}; ctx.ufAcct20ProjectDir = m_ufAcct20Path; - ctx.uftDbSett = (m_supportUftdbSett == "0") ? QString() : m_uftDbSettPath; + ctx.uftDbSett = isSettEnabled() ? m_uftDbSettPath : QString(); QString custJsonPath = Constants::custJson(); - ctx.custJson = readJson(custJsonPath); + if (auto cust = readJson(custJsonPath)) { + ctx.custJson = *cust; + } else { + // 读取失败时置空继续扫描: cust.json 仅用于模块英文名回退, 不应中断整体更新 + LogManager::instance()->logWarning( + "cust.json 读取失败, \"外部接口(不用编译)\"目录的模块英文名回退不可用"); + } Uf2Interface uf2; uf2.setCancelFlag(&m_cancelRequested); // 注入取消标志,递归扫描可快速终止 @@ -329,17 +322,15 @@ bool MetadataProcessor::processUf2Interfaces(const QString& basePath) return ok; } -bool MetadataProcessor::processUft3Interfaces(const QString& basePath) +bool MetadataProcessor::processUft3Interfaces() { LogManager::instance()->log("========== 开始处理 UFT3.0 接口 =========="); - LogManager::instance()->log(QString("基础路径(basePath): %1").arg(basePath)); - - QMap filterDict; - filterDict["completeNameList"] = {"module.xml", "project.xml", "冲突检测.wlua"}; - filterDict["containNameList"] = {".extinterface", ".uftatomfunction", ".uftatomservice", ".uftfunction", ".uftservice"}; + // 实际扫描源 = m_uft30Path(initConfig 配置), 日志如实记录供核对 + LogManager::instance()->log(QString("扫描源目录: %1").arg(m_uft30Path)); ScanContext ctx; - ctx.fileFilterDict = filterDict; + ctx.completeNames = {Constants::MetaPaths::ModuleXml, Constants::MetaPaths::Uft3ProjectFile, Constants::MetaPaths::ConflictWlua}; + ctx.allowedExtensions = {".extinterface", ".uftatomfunction", ".uftatomservice", ".uftfunction", ".uftservice"}; ctx.moduleGenerationCheck = m_moduleGenerationCheck; Uft3Interface uft3; @@ -355,49 +346,39 @@ bool MetadataProcessor::processInterfaces(IInterfaceScanner* scanner, const QString& cacheKey, bool isUft3) { - const QString& db = isUft3 ? "uft3" : "uf20"; + const QString& db = isUft3 ? Constants::Cache::Uft3DbName : Constants::Cache::Uf20DbName; const QString interfaceTable = isUft3 ? Constants::Cache::TblUft3Interface : Constants::Cache::TblUf2Interface; - // src_mtime 对齐 Python: UF2 接口源 = uf20 目录 + ufAcct20 目录 + dbsett 目录(开关开启时, 相加); + // src_mtimes 对齐 Python: UF2 接口源 = uf20 目录 + ufAcct20 目录 + dbsett 目录(开关开启时), // UFT3 接口源 = 传入的工程目录 dirPath(更新 UFT3 时即 uft30 目录)。 - // 注意必须与下方 scanInterfaceFiles 的三个扫描源严格对齐: - // 漏掉 dbsett 时, 该目录内接口变更不会反映到 src_mtime, "无变化跳过"会漏更新 dbsett 接口。 - const double srcMtime = isUft3 - ? dirMtime(dirPath) - : (dirMtime(m_uf20Path) + dirMtime(m_ufAcct20Path) - + ((m_supportUftdbSett == "0") ? 0.0 : dirMtime(m_uftDbSettPath))); - - // 无变化快速跳过: 不勾选"强制全量更新"时, 对比源目录 mtime 与库中上次成功落库记录, - // 一致(容差 0.5 秒)则跳过本次扫描; 返回 -1 表示从未落库, 不跳过。 - if (!m_forceFull) { - const double lastMtime = DataCache::instance()->getMetaSrcMtime(db, interfaceTable); - if (lastMtime >= 0 && qAbs(srcMtime - lastMtime) < 0.5) { - LogManager::instance()->log(QString("%1 源目录无变化(mtime=%2 与库中记录一致),跳过本次扫描") - .arg(cacheKey).arg(srcMtime, 0, 'f', 3)); - return true; + // 逐目录记录而非相加: mtime 跨目录相加可相互抵消(例如 A 目录有文件更新、B 目录被 + // git 操作替换回旧时间戳文件, 和值不变), 会误判"源无变化"而漏更新; 逐目录比较可 + // 准确定位任一源目录的变更。src_mtime 总和仍写入 _meta.src_mtime, 供老记录兼容比较。 + // 注意 mtime 采样源必须与 scanner 的实际扫描源严格对齐: + // 漏掉 dbsett 时, 该目录内接口变更不会反映到 src_mtimes, "无变化跳过"会漏更新 dbsett 接口。 + QJsonArray srcMtimes; + if (isUft3) { + srcMtimes.append(dirMtimeCached(dirPath)); + } else { + srcMtimes.append(dirMtimeCached(m_uf20Path)); + srcMtimes.append(dirMtimeCached(m_ufAcct20Path)); + if (isSettEnabled()) { + srcMtimes.append(dirMtimeCached(m_uftDbSettPath)); } } + double srcMtime = 0.0; // 各目录 mtime 总和(兼容老记录比较与 _meta.src_mtime 列) + for (const QJsonValue& v : srcMtimes) { + srcMtime += v.toDouble(); + } + + // 无变化快速跳过: 不勾选"强制全量更新"时, 对比源目录 mtime 与库中上次成功落库记录 + if (trySkipUnchanged(db, interfaceTable, srcMtimes, srcMtime, cacheKey)) { + return true; + } + QJsonObject resultJson; QJsonObject transCodeJson; - - { - QElapsedTimer timer; - timer.start(); - - ScanResult scanResult = scanner->scanDir(dirPath, baseCtx); - QMap resultMap = scanResult.resultMap; - QMap transCodeMap = scanResult.transCodeMap; - - for (const QString& key : resultMap.keys()) { - const InterfaceInfo& info = resultMap[key]; - resultJson[key] = isUft3 ? interfaceInfoToUFT3Json(info) : interfaceInfoToUF2Json(info); - } - for (const QString& key : transCodeMap.keys()) { - const InterfaceInfo& info = transCodeMap[key]; - transCodeJson[key] = interfaceInfoToUFT3Json(info); - } - LogManager::instance()->log(QString("%1 全量扫描耗时: %2ms").arg(cacheKey).arg(timer.elapsed())); - } + collectInterfaceRows(scanner, baseCtx, dirPath, cacheKey, isUft3, resultJson, transCodeJson); // 协作式取消:页面析构时请求取消,此处跳过落库直接返回 if (isCancelRequested()) { @@ -405,15 +386,89 @@ bool MetadataProcessor::processInterfaces(IInterfaceScanner* scanner, return false; } - // 解析结果直接落库, 不再生成中间 JSON 文件 + // 解析结果直接落库, 不再生成中间 JSON 文件; 落库失败/取消均以 false 上抛 + return saveInterfaceRows(isUft3, db, interfaceTable, resultJson, transCodeJson, + srcMtime, srcMtimes, cacheKey); +} + +// 无变化快速跳过判断: 逐目录 mtime 精确比较优先, 老记录(无逐目录列)回退按总和比较。 +// 返回 true 表示源未变化可直接跳过本次扫描; 强制全量/已变更/从未落库返回 false。 +bool MetadataProcessor::trySkipUnchanged(const QString& db, const QString& table, + const QJsonArray& srcMtimes, double srcMtime, + const QString& cacheKey) +{ + if (m_forceFull) { + return false; + } + const QJsonArray lastMtimes = DataCache::instance()->getMetaSrcMtimes(db, table); + if (!lastMtimes.isEmpty()) { + // 新记录(有逐目录 mtime): 目录个数不同(如 dbsett 开关切换)或任一目录 + // mtime 变化(容差见 kMtimeToleranceSec)即视为已变更 + bool unchanged = (lastMtimes.size() == srcMtimes.size()); + for (int i = 0; unchanged && i < srcMtimes.size(); ++i) { + unchanged = qAbs(srcMtimes.at(i).toDouble() - lastMtimes.at(i).toDouble()) < kMtimeToleranceSec; + } + if (unchanged) { + LogManager::instance()->log(QString("%1 源目录无变化(逐目录 mtime 与库中记录一致),跳过本次扫描") + .arg(cacheKey)); + return true; + } + return false; + } + // 老记录(升级前只有 src_mtime 总和): 回退按总和比较, 升级后首次成功落库 + // 起即有逐目录记录, 之后走上方精确比较 + const double lastMtime = DataCache::instance()->getMetaSrcMtime(db, table); + if (lastMtime >= 0 && qAbs(srcMtime - lastMtime) < kMtimeToleranceSec) { + LogManager::instance()->log(QString("%1 源目录无变化(mtime=%2 与库中记录一致),跳过本次扫描") + .arg(cacheKey).arg(srcMtime, 0, 'f', 3)); + return true; + } + return false; +} + +// 全量扫描 + 行转换: 结果填入 resultJson(接口)与 transCodeJson(转码, 仅 UFT3 非空)。 +// 扫描器内部已有协作式取消检查点, 扫描转换后由调用方做落库前的兜底取消判断。 +void MetadataProcessor::collectInterfaceRows(IInterfaceScanner* scanner, const ScanContext& baseCtx, + const QString& dirPath, const QString& cacheKey, bool isUft3, + QJsonObject& resultJson, QJsonObject& transCodeJson) +{ + QElapsedTimer timer; + timer.start(); + + const ScanResult scanResult = scanner->scanDir(dirPath, baseCtx); + const QMap& resultMap = scanResult.resultMap; + const QMap& transCodeMap = scanResult.transCodeMap; + + for (auto it = resultMap.constBegin(); it != resultMap.constEnd(); ++it) { + resultJson[it.key()] = isUft3 ? interfaceInfoToUFT3Row(it.value()) : interfaceInfoToUF2Row(it.value()); + } + for (auto it = transCodeMap.constBegin(); it != transCodeMap.constEnd(); ++it) { + transCodeJson[it.key()] = interfaceInfoToUFT3Row(it.value()); + } + LogManager::instance()->log(QString("%1 全量扫描耗时: %2ms").arg(cacheKey).arg(timer.elapsed())); +} + +// 接口行落库: UFT3 另落转码表(源无转码数据时落空表覆盖上一轮残留行)。 +// 返回 false 表示任一表落库失败(含落库阶段取消, 见 DataCache::saveDataset 的 cancelFlag)。 +bool MetadataProcessor::saveInterfaceRows(bool isUft3, const QString& db, const QString& interfaceTable, + const QJsonObject& resultJson, const QJsonObject& transCodeJson, + double srcMtime, const QJsonArray& srcMtimes, + const QString& cacheKey) +{ bool ok = true; if (isUft3) { - ok = saveRowsFromJson("uft3", Constants::Cache::TblUft3Interface, resultJson, srcMtime) && ok; + ok = saveRowsToDb(Constants::Cache::Uft3DbName, Constants::Cache::TblUft3Interface, resultJson, srcMtime, srcMtimes) && ok; if (!transCodeJson.isEmpty()) { - ok = saveRowsFromJson("uft3", Constants::Cache::TblUft3TransCode, transCodeJson, srcMtime) && ok; + ok = saveRowsToDb(Constants::Cache::Uft3DbName, Constants::Cache::TblUft3TransCode, transCodeJson, srcMtime, srcMtimes) && ok; + } else { + // 源工程已无转码接口时落一版空表覆盖上一轮残留行: 只跳过落库会让旧转码行 + // 永久残留并被 buildFunctionCache 并入功能缓存, 过期功能号持续出现在搜索结果中 + LogManager::instance()->logInfo("uft3_trans_code 本次无转码数据, 落空表覆盖上一轮残留行"); + ok = DataCache::instance()->saveDataset(Constants::Cache::Uft3DbName, Constants::Cache::TblUft3TransCode, + QMap(), srcMtime, srcMtimes, &m_cancelRequested) && ok; } } else { - ok = saveRowsFromJson("uf20", Constants::Cache::TblUf2Interface, resultJson, srcMtime) && ok; + ok = saveRowsToDb(Constants::Cache::Uf20DbName, Constants::Cache::TblUf2Interface, resultJson, srcMtime, srcMtimes) && ok; } if (ok) { @@ -426,383 +481,174 @@ bool MetadataProcessor::processInterfaces(IInterfaceScanner* scanner, bool MetadataProcessor::processUf2DataType() { - LogManager::instance()->log("========== 开始处理 UF2.0 数据类型 =========="); - QElapsedTimer timer; - timer.start(); - - QString dirPath = m_uf20Path; - - Uf2DataType uf2DataType; - auto resultMap = uf2DataType.loadData(dirPath); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 数据类型处理已取消,跳过落库"); - return false; - } - const QMap& typeInfo = resultMap[key]; - QJsonObject typeJson; - for (const QString& attrKey : typeInfo.keys()) { - typeJson[attrKey] = typeInfo[attrKey]; - } - resultJson[key] = typeJson; - } - - // 解析结果直接落库 uf20.uf2_datatype, 不再生成中间 JSON(uf2dataType.json) - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2DataType, resultJson, dirMtime(m_uf20Path)); - - LogManager::instance()->log(QString("UF2.0 数据类型处理完成,耗时: %1ms,已落库: uf20.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUf2DataType)); - - return saved; // 落库失败上抛, 不再恒报成功 + Uf2DataType parser; + return runParseAndSave("UF2.0 数据类型", parser, + &simpleEntryToJson, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2DataType, + m_uf20Path); } +// 从缓存库读取数据类型表(name -> 属性键值对), UF2.0/UFT3.0 标准字段处理共用(仅库名/表名不同): +// 行 JSON 非法(历史脏数据/写库中断)时告警并跳过该行, 静默转空对象会让该数据类型属性全部丢失, +// 标准字段解析出现难以定位的空值; 读到的表为空时同样告警(通常意味着未先执行"数据类型"更新) +static QMap> loadDatatypeMap(const QString& db, + const QString& table, + const QString& logTag) +{ + QMap> result; + if (DataCache::instance()->initDatabase()) { + const QMap datatypeRows = DataCache::instance()->getAllRows(db, table); + for (auto it = datatypeRows.begin(); it != datatypeRows.end(); ++it) { + QJsonParseError parseErr; + const QJsonDocument doc = QJsonDocument::fromJson(it.value().toUtf8(), &parseErr); + if (doc.isNull()) { + LogManager::instance()->logWarning(QString("元数据读取 - %1 行 %2 JSON 解析失败(%3), 该行已跳过") + .arg(logTag, it.key(), parseErr.errorString())); + continue; + } + const QJsonObject typeObj = doc.object(); + QMap typeInfo; + for (auto attrIt = typeObj.constBegin(); attrIt != typeObj.constEnd(); ++attrIt) { + typeInfo[attrIt.key()] = attrIt.value().toString(); + } + result[it.key()] = typeInfo; + } + } + if (result.isEmpty()) { + LogManager::instance()->logWarning( + QString("%1 缓存表为空, 标准字段解析将缺失数据类型信息(请确认已先执行\"数据类型\"更新)").arg(logTag)); + } + return result; +} + + bool MetadataProcessor::processUf2StdFields() { - LogManager::instance()->log("========== 开始处理 UF2.0 标准字段 =========="); - QElapsedTimer timer; - timer.start(); - - QString dirPath = m_uf20Path; - // 数据类型依赖从缓存库 uf20.uf2_datatype 读取, 不再读中间 JSON(uf2dataType.json) - QMap> uf2datatypeMap; - if (DataCache::instance()->initDatabase()) { - QMap datatypeRows = - DataCache::instance()->getAllRows("uf20", Constants::Cache::TblUf2DataType); - for (auto it = datatypeRows.begin(); it != datatypeRows.end(); ++it) { - QJsonObject typeObj = QJsonDocument::fromJson(it.value().toUtf8()).object(); - QMap typeInfo; - for (const QString& attrKey : typeObj.keys()) { - typeInfo[attrKey] = typeObj[attrKey].toString(); - } - uf2datatypeMap[it.key()] = typeInfo; - } - } - if (uf2datatypeMap.isEmpty()) { - LogManager::instance()->logWarning( - "uf20.uf2_datatype 缓存表为空, 标准字段解析将缺失数据类型信息" - "(请确认已先执行\"数据类型\"更新)"); - } + const QMap> uf2datatypeMap = + loadDatatypeMap(Constants::Cache::Uf20DbName, Constants::Cache::TblUf2DataType, "uf20.uf2_datatype"); - Uf2StdFields uf2StdFields; - auto resultMap = uf2StdFields.loadData(dirPath, uf2datatypeMap); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 标准字段处理已取消,跳过落库"); - return false; - } - const QMap& fieldInfo = resultMap[key]; - QJsonObject fieldJson; - for (const QString& attrKey : fieldInfo.keys()) { - fieldJson[attrKey] = fieldInfo[attrKey]; - } - resultJson[key] = fieldJson; - } - - // 解析结果直接落库 uf20.uf2_stdfield, 不再生成中间 JSON(uf2stdfield.json) - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2StdField, resultJson, dirMtime(m_uf20Path)); - - LogManager::instance()->log(QString("UF2.0 标准字段处理完成,耗时: %1ms,已落库: uf20.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUf2StdField)); - - return saved; // 落库失败上抛, 不再恒报成功 + Uf2StdFields parser; + return runParseAndSave("UF2.0 标准字段", parser, + &simpleEntryToJson, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2StdField, + m_uf20Path, uf2datatypeMap); } bool MetadataProcessor::processUf2ErrNumber() { - LogManager::instance()->log("========== 开始处理 UF2.0 标准错误号 =========="); - QElapsedTimer timer; - timer.start(); - - QString dirPath = m_uf20Path; - - Uf2ErrNumber uf2ErrNumber; - auto resultMap = uf2ErrNumber.loadData(dirPath); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 标准错误号处理已取消,跳过落库"); - return false; - } - const QMap& errorInfo = resultMap[key]; - QJsonObject errorJson; - for (const QString& attrKey : errorInfo.keys()) { - errorJson[attrKey] = errorInfo[attrKey]; - } - resultJson[key] = errorJson; - } - - // 解析结果直接落库 uf20.uf2_error, 不再生成中间 JSON(uf2Error.json) - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2Error, resultJson, dirMtime(m_uf20Path)); - - LogManager::instance()->log(QString("UF2.0 标准错误号处理完成,耗时: %1ms,已落库: uf20.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUf2Error)); - - return saved; // 落库失败上抛, 不再恒报成功 + Uf2ErrNumber parser; + return runParseAndSave("UF2.0 标准错误号", parser, + &simpleEntryToJson, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2Error, + m_uf20Path); } bool MetadataProcessor::processUf2Constants() { - LogManager::instance()->log("========== 开始处理 UF2.0 常量 =========="); - QElapsedTimer timer; - timer.start(); - - Uf2Constants uf2Constants; - auto resultMap = uf2Constants.loadData(m_uf20Path); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 常量处理已取消,跳过落库"); - return false; - } - const QMap& info = resultMap[key]; - QJsonObject entry; - for (const QString& attrKey : info.keys()) { - entry[attrKey] = info[attrKey]; - } - resultJson[key] = entry; - } - - // 解析结果直接落库 uf20.uf2_constants, 不再生成中间 JSON(uf2constants.json) - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2Constants, resultJson, dirMtime(m_uf20Path)); - - LogManager::instance()->log(QString("UF2.0 常量处理完成,耗时: %1ms,已落库: uf20.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUf2Constants)); - return saved; // 落库失败上抛, 不再恒报成功 + Uf2Constants parser; + return runParseAndSave("UF2.0 常量", parser, + &simpleEntryToJson, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2Constants, + m_uf20Path); } bool MetadataProcessor::processUf2MenuFunc() { - LogManager::instance()->log("========== 开始处理 UF2.0 菜单功能 =========="); - QElapsedTimer timer; - timer.start(); - Uf2MenuAndFunction parser; - auto resultMap = parser.loadData(m_uf20Path); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 菜单功能处理已取消,跳过落库"); - return false; - } - const QMap& info = resultMap[key]; - QJsonObject entry; - for (const QString& attrKey : info.keys()) { - entry[attrKey] = info[attrKey]; - } - resultJson[key] = entry; - } - - // 解析结果只落库 uf20.uf2_menu_func(不再生成 uf2_menu 表与中间 JSON)。 - // Python 侧 schema 也只定义 uf2_menu_func 一个数据集, 两者保持对齐。 - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2MenuFunc, resultJson, dirMtime(m_uf20Path)); - - LogManager::instance()->log(QString("UF2.0 菜单功能处理完成,共 %1 条,耗时: %2ms,已落库: uf20.%3") - .arg(resultJson.size()) - .arg(timer.elapsed()) - .arg(Constants::Cache::TblUf2MenuFunc)); - return saved; // 落库失败上抛, 不再恒报成功 + return runParseAndSave("UF2.0 菜单功能", parser, + &simpleEntryToJson, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2MenuFunc, + m_uf20Path); } bool MetadataProcessor::processUf2Module() { - LogManager::instance()->log("========== 开始处理 UF2.0 模块 =========="); - QElapsedTimer timer; - timer.start(); - Uf2Module parser; - auto resultMap = parser.loadData(m_uf20Path); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 模块处理已取消,跳过落库"); - return false; - } - const QMap& info = resultMap[key]; - QJsonObject entry; - for (const QString& attrKey : info.keys()) { - entry[attrKey] = info[attrKey]; - } - resultJson[key] = entry; - } - - // 解析结果直接落库 uf20.uf2_module, 不再生成中间 JSON(uf2module.json) - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2Module, resultJson, dirMtime(m_uf20Path)); - - LogManager::instance()->log(QString("UF2.0 模块处理完成,耗时: %1ms,已落库: uf20.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUf2Module)); - return saved; // 落库失败上抛, 不再恒报成功 + // 注入协作式取消标志: 全工程树递归扫描期间 requestCancel 后可提前中断 + parser.setCancelFlag(&m_cancelRequested); + return runParseAndSave("UF2.0 模块", parser, + &simpleEntryToJson, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2Module, + m_uf20Path); } bool MetadataProcessor::processUf2Table() { - LogManager::instance()->log("========== 开始处理 UF2.0 表结构 =========="); - QElapsedTimer timer; - timer.start(); - Uf2Table uf2Table; - QString settPath = (m_supportUftdbSett == "0") ? QString() : m_uftDbSettPath; - auto resultMap = uf2Table.loadData(m_uf20Path, settPath); + // 注入协作式取消标志: 大目录树递归扫描期间 requestCancel 后可提前中断 + uf2Table.setCancelFlag(&m_cancelRequested); + QString settPath = isSettEnabled() ? m_uftDbSettPath : QString(); - QJsonObject resultJson; - for (auto it = resultMap.begin(); it != resultMap.end(); ++it) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UF2.0 表结构处理已取消,跳过落库"); - return false; - } - resultJson[it.key()] = it.value(); + // mtime 采样源与 loadData 扫描源严格一致: uf20 目录 + 数据库设置目录(开关开启时)。 + // 逐目录记录(与 processInterfaces 同因): 总和相加可相互抵消, 开关 0→1 切换时若两目录 + // 均无文件变化, 总和不变会被误判"无变化"而漏更新 sett 表结构; 逐目录数组 size 变化 + // 必然触发重扫。开关关闭时不取 sett 目录 mtime, 消除空路径警告日志噪音。 + QStringList srcDirs{m_uf20Path}; + if (isSettEnabled()) { + srcDirs << m_uftDbSettPath; } - // 解析结果直接落库 uf20.uf2_table, 不再生成中间 JSON(uf2table.json) - // src_mtime 对齐 Python: uf2_table 源 = uf20 目录 + 数据库设置目录(相加) - const bool saved = saveRowsFromJson("uf20", Constants::Cache::TblUf2Table, resultJson, - dirMtime(m_uf20Path) + dirMtime(m_uftDbSettPath)); - - LogManager::instance()->log(QString("UF2.0 表结构处理完成,耗时: %1ms,已落库: uf20.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUf2Table)); - return saved; // 落库失败上抛, 不再恒报成功 + // 双源(loadData(m_uf20Path, settPath))走多源管线: mtime 解析前采样, + // 取消检查/落库/日志复用通用实现, 消除手写同型循环 + return runParseAndSaveMultiSrc("UF2.0 表结构", uf2Table, + [](const QString&, const QJsonObject& v) { return v; }, + Constants::Cache::Uf20DbName, Constants::Cache::TblUf2Table, + m_uf20Path, srcDirs, settPath); } bool MetadataProcessor::processUft3DataType() { - LogManager::instance()->log("========== 开始处理 UFT3.0 数据类型 =========="); - QElapsedTimer timer; - timer.start(); - - QString dirPath = m_uft30PubPath; - - Uft3DataType uft3DataType; - auto resultMap = uft3DataType.loadData(dirPath); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UFT3.0 数据类型处理已取消,跳过落库"); - return false; - } - const QMap& typeInfo = resultMap[key]; - QJsonObject typeJson; - for (const QString& attrKey : typeInfo.keys()) { - typeJson[attrKey] = typeInfo[attrKey]; - } - resultJson[key] = typeJson; - } - - // 解析结果直接落库 uft3.uft3_datatype, 不再生成中间 JSON(uft3datatype.json) - const bool saved = saveRowsFromJson("uft3", Constants::Cache::TblUft3DataType, resultJson, dirMtime(m_uft30PubPath)); - - LogManager::instance()->log(QString("UFT3.0 数据类型处理完成,耗时: %1ms,已落库: uft3.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUft3DataType)); - - return saved; // 落库失败上抛, 不再恒报成功 + Uft3DataType parser; + return runParseAndSave("UFT3.0 数据类型", parser, + &simpleEntryToJson, + Constants::Cache::Uft3DbName, Constants::Cache::TblUft3DataType, + m_uft30PubPath); } bool MetadataProcessor::processUft3StdFields() { - LogManager::instance()->log("========== 开始处理 UFT3.0 标准字段 =========="); - QElapsedTimer timer; - timer.start(); - - QString dirPath = m_uft30PubPath; - // 数据类型依赖从缓存库 uft3.uft3_datatype 读取, 不再读中间 JSON(uft3datatype.json) - QMap> uf3datatypeMap; - if (DataCache::instance()->initDatabase()) { - QMap datatypeRows = - DataCache::instance()->getAllRows("uft3", Constants::Cache::TblUft3DataType); - for (auto it = datatypeRows.begin(); it != datatypeRows.end(); ++it) { - QJsonObject typeObj = QJsonDocument::fromJson(it.value().toUtf8()).object(); - QMap typeInfo; - for (const QString& attrKey : typeObj.keys()) { - typeInfo[attrKey] = typeObj[attrKey].toString(); - } - uf3datatypeMap[it.key()] = typeInfo; - } - } - if (uf3datatypeMap.isEmpty()) { - LogManager::instance()->logWarning( - "uft3.uft3_datatype 缓存表为空, 标准字段解析将缺失数据类型信息" - "(请确认已先执行\"数据类型\"更新)"); - } + const QMap> uf3datatypeMap = + loadDatatypeMap(Constants::Cache::Uft3DbName, Constants::Cache::TblUft3DataType, "uft3.uft3_datatype"); - Uft3StdFields uft3StdFields; - auto resultMap = uft3StdFields.loadData(dirPath, uf3datatypeMap); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UFT3.0 标准字段处理已取消,跳过落库"); - return false; - } - const QMap& fieldInfo = resultMap[key]; - QJsonObject fieldJson; - for (const QString& attrKey : fieldInfo.keys()) { - fieldJson[attrKey] = fieldInfo[attrKey]; - } - resultJson[key] = fieldJson; - } - - // 解析结果直接落库 uft3.uft3_stdfield, 不再生成中间 JSON(uft3stdfields.json) - const bool saved = saveRowsFromJson("uft3", Constants::Cache::TblUft3StdField, resultJson, dirMtime(m_uft30PubPath)); - - LogManager::instance()->log(QString("UFT3.0 标准字段处理完成,耗时: %1ms,已落库: uft3.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUft3StdField)); - - return saved; // 落库失败上抛, 不再恒报成功 + Uft3StdFields parser; + return runParseAndSave("UFT3.0 标准字段", parser, + &simpleEntryToJson, + Constants::Cache::Uft3DbName, Constants::Cache::TblUft3StdField, + m_uft30PubPath, uf3datatypeMap); } bool MetadataProcessor::processUft3Table() { - LogManager::instance()->log("========== 开始处理 UFT3.0 表结构 =========="); - QElapsedTimer timer; - timer.start(); - - QString dirPath = m_uft30PubPath; - - Uft3Table uft3Table; - auto resultMap = uft3Table.loadData(dirPath); - - QJsonObject resultJson; - for (const QString& key : resultMap.keys()) { - if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 - LogManager::instance()->log("UFT3.0 表结构处理已取消,跳过落库"); - return false; - } - const QMap& tableInfo = resultMap[key]; + // 行转换: QMap 属性表 → JSON 对象。 + // canConvert 的直连转换不含 QStringList 等容器, + // 用 fromVariant 兜底, 避免 fieldList(字段列表)等属性被静默丢弃 + auto convert = [](const QString& tableName, const QMap& tableInfo) -> QJsonObject { QJsonObject tableJson; - for (const QString& attrKey : tableInfo.keys()) { - QVariant value = tableInfo[attrKey]; + for (auto attrIt = tableInfo.constBegin(); attrIt != tableInfo.constEnd(); ++attrIt) { + const QString& attrKey = attrIt.key(); + const QVariant& value = attrIt.value(); if (value.canConvert()) { tableJson[attrKey] = value.toJsonValue(); } else { - // canConvert 的直连转换不含 QStringList 等容器, - // 用 fromVariant 兜底, 避免 fieldList(字段列表)等属性被静默丢弃 const QJsonValue jv = QJsonValue::fromVariant(value); if (jv.isNull() || jv.isUndefined()) { LogManager::instance()->logWarning( QString("表 %1 的属性 %2 无法转为 JSON(类型 %3), 已跳过") - .arg(key, attrKey, value.typeName())); + .arg(tableName, attrKey, value.typeName())); } else { tableJson[attrKey] = jv; } } } - resultJson[key] = tableJson; - } + return tableJson; + }; - // 解析结果直接落库 uft3.uft3_table, 不再生成中间 JSON(uft3Table.json) - const bool saved = saveRowsFromJson("uft3", Constants::Cache::TblUft3Table, resultJson, dirMtime(m_uft30PubPath)); - - LogManager::instance()->log(QString("UFT3.0 表结构处理完成,耗时: %1ms,已落库: uft3.%2") - .arg(timer.elapsed()).arg(Constants::Cache::TblUft3Table)); - - return saved; // 落库失败上抛, 不再恒报成功 -} \ No newline at end of file + Uft3Table parser; + // 注入协作式取消标志: 大目录树递归扫描/逐文件解析期间 requestCancel 后可提前中断 + parser.setCancelFlag(&m_cancelRequested); + return runParseAndSave("UFT3.0 表结构", parser, convert, + Constants::Cache::Uft3DbName, Constants::Cache::TblUft3Table, + m_uft30PubPath); +} diff --git a/src/metadataupdate/metadataprocessor.h b/src/metadataupdate/metadataprocessor.h index 857ac01..8e12fed 100644 --- a/src/metadataupdate/metadataprocessor.h +++ b/src/metadataupdate/metadataprocessor.h @@ -4,11 +4,16 @@ #include #include #include +#include #include #include +#include +#include +#include #include -#include "interface_scanner.h" +#include "common/interface_scanner.h" +#include "utils/logmanager.h" class MetadataProcessor : public QObject { @@ -16,8 +21,8 @@ class MetadataProcessor : public QObject public: explicit MetadataProcessor(QObject *parent = nullptr); - bool processUf2Interfaces(const QString& basePath); - bool processUft3Interfaces(const QString& basePath); + bool processUf2Interfaces(); + bool processUft3Interfaces(); void setForceFull(bool force); // 强制全量更新(兼容 UI;原版每次均全量解析,无增量缓存时不影响行为) @@ -26,6 +31,9 @@ public: // 启动新任务前重置取消标志 void resetCancel() { m_cancelRequested.store(false, std::memory_order_relaxed); } bool isCancelRequested() const { return m_cancelRequested.load(std::memory_order_relaxed); } + // 启动新更新运行前清空单运行内缓存(页面在 startUpdate 中调用): + // dirMtime 全树遍历结果只在一次运行内有效, 跨运行必须重扫感知文件变更 + void beginUpdateRun() { m_dirMtimeCache.clear(); } bool processUf2StdFields(); bool processUf2DataType(); @@ -38,25 +46,111 @@ public: bool processUft3StdFields(); bool processUft3DataType(); bool processUft3Table(); - - QString getBasePath() const; - void setBasePath(const QString& path); - -public: - void initConfig(const QString& basePath); - void reloadPaths(); // 从 ConfigManager 刷新缓存的路径 - -private: - bool isValidUtf8(const QByteArray& data); - QJsonObject readJson(const QString& filePath); // 仅用于读取输入文件 cust.json - QJsonObject interfaceInfoToUFT3Json(const InterfaceInfo& info); - QJsonObject interfaceInfoToUF2Json(const InterfaceInfo& info); - // 把解析得到的 QJsonObject(key -> 子对象) 直接落库到双库对应表(name 列=key, data 列=子对象JSON)。 - // 不再生成任何中间 JSON 文件。db 选 "uf20"/"uft3", table 为 Constants::Cache 表名。 - // srcMtime 为源目录最新修改时间(多源时相加), 写入 _meta.src_mtime 供缓存失效判断。 - bool saveRowsFromJson(const QString& db, const QString& table, const QJsonObject& resultJson, - double srcMtime = 0.0); +public: + void initConfig(); + void reloadPaths(); // 从 ConfigManager 刷新缓存的路径 + void loadConfigPaths(); // initConfig/reloadPaths 共用的路径+开关加载(收敛两处逐字重复) + +private: + // sett(dbsett)工程开关是否启用: "0"=关闭, 其余(含未配置空值)=启用。 + // 收敛 4 处 m_supportUftdbSett == "0" 字面比较, 防止比较语义漂移 + bool isSettEnabled() const { return m_supportUftdbSett != QStringLiteral("0"); } + + // 读取 JSON 文件, 打不开/解析失败返回 std::nullopt(错误日志已落), + // 与"文件存在但内容为空对象"区分, 调用方可据此告警或降级 + std::optional readJson(const QString& filePath); + QJsonObject interfaceInfoToUFT3Row(const InterfaceInfo& info); + QJsonObject interfaceInfoToUF2Row(const InterfaceInfo& info); + + // 单次更新运行内的 dirMtime 结果缓存: 同一棵大目录树在一个运行内会被 + // 接口/数据类型/标准字段等各数据集独立扫描(全选时 UF20 源树约扫 9 次/UFT3 约 5 次), + // 全树 QDirIterator 遍历是元数据更新的主要耗时来源之一, 缓存后每运行仅扫一遍。 + // 更新运行经页面 _watcher->isRunning() 串行防重入, beginUpdateRun 在主线程 + // 启动新运行前清空并经 QtConcurrent 任务派发建立 happens-before, 无需加锁。 + double dirMtimeCached(const QString& path); + + // 把内存中解析得到的 QJsonObject(key -> 子对象) 序列化后落库到双库对应表 + // (name 列=key, data 列=子对象JSON 字符串), 即内存 -> 缓存库方向, 不生成任何中间 JSON 文件。 + // db 选 "uf20"/"uft3", table 为 Constants::Cache 表名。 + // srcMtime 为源目录最新修改时间(多源时相加), 写入 _meta.src_mtime 供缓存失效判断; + // srcMtimes 非空时逐目录 mtime 数组一并写入 _meta.src_mtimes(多源数据集逐目录比较, + // 防 mtime 相加抵消导致"无变化"误判漏更新)。 + bool saveRowsToDb(const QString& db, const QString& table, const QJsonObject& resultJson, + double srcMtime = 0.0, const QJsonArray& srcMtimes = QJsonArray()); + + // 无变化快速跳过判断(详见 .cpp): 源未变化返回 true, 强制全量/已变更/从未落库返回 false + bool trySkipUnchanged(const QString& db, const QString& table, + const QJsonArray& srcMtimes, double srcMtime, const QString& cacheKey); + + // 全量扫描 + 行转换: 结果填入 resultJson 与 transCodeJson(仅 UFT3 非空) + void collectInterfaceRows(IInterfaceScanner* scanner, const ScanContext& baseCtx, + const QString& dirPath, const QString& cacheKey, bool isUft3, + QJsonObject& resultJson, QJsonObject& transCodeJson); + + // 接口行落库(UFT3 另落转码表), false = 任一表落库失败(含落库阶段取消) + bool saveInterfaceRows(bool isUft3, const QString& db, const QString& interfaceTable, + const QJsonObject& resultJson, const QJsonObject& transCodeJson, + double srcMtime, const QJsonArray& srcMtimes, const QString& cacheKey); + + // 简单条目通用管线(模板收敛各 processXxx 平行实现): + // mtime 采样(解析前) → 解析器 loadData(扫描目录[, 额外参数]) → 逐行 convert(key, value) + // 转 JSON(循环内协作式取消检查, 取消时中止并返回 false) → 落库 → 完成/取消日志。 + // srcDirs 为 mtime 采样源目录, 必须在 loadData 之前采样: 采样若晚于解析, 解析期间 + // 发生的修改会记入本次 mtime 却未包含在本次结果中, 下次比对将被误判"无变化"而永久漏更新。 + // 逐目录记录 srcMtimes, 防多源 mtime 相加抵消导致"无变化"误判。 + // convert 统一 (key, value) 双参签名: 简单条目忽略 key, 表结构类用 key 拼警告日志 + template + bool runParseAndSaveMultiSrc(const QString& tag, Parser& parser, Convert convert, + const QString& db, const QString& table, + const QString& scanDir, const QStringList& srcDirs, + ExtraArgs&&... extraArgs) + { + LogManager::instance()->log(QString("========== 开始处理 %1 ==========").arg(tag)); + QElapsedTimer timer; + timer.start(); + + QJsonArray srcMtimes; + double srcMtime = 0.0; + for (const QString& dir : srcDirs) { + const double m = dirMtimeCached(dir); + srcMtimes.append(m); + srcMtime += m; + } + + auto resultMap = parser.loadData(scanDir, std::forward(extraArgs)...); + + QJsonObject resultJson; + for (auto it = resultMap.constBegin(); it != resultMap.constEnd(); ++it) { + if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 + LogManager::instance()->log(QString("%1处理已取消,跳过落库").arg(tag)); + return false; + } + resultJson[it.key()] = convert(it.key(), it.value()); + } + + const bool saved = saveRowsToDb(db, table, resultJson, srcMtime, srcMtimes); + + if (saved) { + LogManager::instance()->log(QString("%1处理完成,耗时: %2ms,已落库: %3.%4") + .arg(tag).arg(timer.elapsed()).arg(db, table)); + } else { + // 落库失败不得仍打"已落库"成功文案(状态栏会误报更新成功, 排障方向被误导) + LogManager::instance()->logError(QString("%1落库失败, 本次更新未写入缓存库").arg(tag)); + } + return saved; // 落库失败上抛, 不再恒报成功 + } + + // 单源便捷入口: mtime 采样源 = 扫描目录本身(现有各调用点签名不变) + template + bool runParseAndSave(const QString& tag, Parser& parser, Convert convert, + const QString& db, const QString& table, + const QString& scanDir, ExtraArgs&&... extraArgs) + { + return runParseAndSaveMultiSrc(tag, parser, std::move(convert), db, table, scanDir, + QStringList{scanDir}, + std::forward(extraArgs)...); + } // 统一处理入口:通过 IInterfaceScanner 策略多态处理,消除 UF2/UFT3 平行实现。 // 解析结果直接落双库(uf20.uf2_interface / uft3.uft3_interface / uft3.uft3_transcode)。 @@ -68,24 +162,22 @@ private: // 注: C++ 直接解析源文件并落双库对应表(name 列=key, data 列=JSON 序列化字符串), // 落库路径 /cache/uf20_cache.db 与 /cache/uft3_cache.db(见 DataCache)。 - // 各 processUf2Xxx/processUft3Xxx 解析后调用 saveRowsFromJson 落库, 不再生成中间 JSON 文件。 + // 各 processUf2Xxx/processUft3Xxx 解析后调用 saveRowsToDb 落库, 不再生成中间 JSON 文件。 - QString m_basePath; - QString m_uf2touft3Path; - QString m_uf20Path; QString m_ufAcct20Path; QString m_uftDbSettPath; QString m_uft30PubPath; - QString m_uft30CbptransPath; QString m_uft30Path; QString m_supportUftdbSett; QString m_moduleGenerationCheck; + QHash m_dirMtimeCache; // 单次更新运行内的 dirMtime 结果缓存(见 dirMtimeCached) + bool m_forceFull = false; // 强制全量更新标志(兼容 UI;原版每次均全量解析,无增量缓存时不影响行为) std::atomic_bool m_cancelRequested{false}; // 协作式取消标志(原子,供后台线程读取) }; -#endif // METADATAPROCESSOR_H \ No newline at end of file +#endif // METADATAPROCESSOR_H diff --git a/src/metadataupdate/uf2constants.cpp b/src/metadataupdate/uf20/uf2constants.cpp similarity index 74% rename from src/metadataupdate/uf2constants.cpp rename to src/metadataupdate/uf20/uf2constants.cpp index 167f03b..b1bfe0c 100644 --- a/src/metadataupdate/uf2constants.cpp +++ b/src/metadataupdate/uf20/uf2constants.cpp @@ -12,7 +12,9 @@ Uf2Constants::Uf2Constants(QObject *parent) : QObject(parent) QMap> Uf2Constants::loadData(const QString& dirPath) { QMap> resultMap; - QString filePath = dirPath + Constants::MetaPaths::Uf2Constants; + // 与其余解析器统一: 先 normPath 规范分隔符, 避免混合 '/' '\\' 拼接 + QString filePath = PathUtils::normPath(dirPath); + filePath += Constants::MetaPaths::Uf2Constants; QFile file(filePath); if (!file.exists()) { // 文件缺失是路径配置错误/工程不完整的信号, 静默返回空表无任何日志可查 @@ -32,12 +34,15 @@ QMap> Uf2Constants::loadData(const QString& dirP if (reader.isStartElement() && reader.name().compare("constant", Qt::CaseInsensitive) == 0) { QXmlStreamAttributes attrs = reader.attributes(); QString name = attrs.value("name").toString().trimmed(); - QString value = attrs.value("constant_value").toString().trimmed(); + // 源条目属性: name=英文名(表 key), description=中文名称(cname), value=常量值 + // 旧实现取不存在的 constant_value 属性导致 value 恒为空, cname 又误存英文名 + QString value = attrs.value("value").toString().trimmed(); + QString cname = attrs.value("description").toString().trimmed(); if (name.isEmpty()) { continue; } QMap entry; - entry["cname"] = name; + entry["cname"] = cname; entry["value"] = value; resultMap[name] = entry; // key = constant_name } diff --git a/src/metadataupdate/uf2constants.h b/src/metadataupdate/uf20/uf2constants.h similarity index 100% rename from src/metadataupdate/uf2constants.h rename to src/metadataupdate/uf20/uf2constants.h diff --git a/src/metadataupdate/uf2datatype.cpp b/src/metadataupdate/uf20/uf2datatype.cpp similarity index 89% rename from src/metadataupdate/uf2datatype.cpp rename to src/metadataupdate/uf20/uf2datatype.cpp index 628631a..5685dff 100644 --- a/src/metadataupdate/uf2datatype.cpp +++ b/src/metadataupdate/uf20/uf2datatype.cpp @@ -1,7 +1,6 @@ #include "uf2datatype.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "utils/Constants.h" #include #include #include @@ -20,8 +19,14 @@ QMap> Uf2DataType::loadData(const QString& dirPa LogManager::instance()->log(QString("加载UF2数据类型文件: %1").arg(filePath)); QFile file(filePath); + // 与 uf2constants/uf2menuandfunction 统一日志分级: + // 文件缺失(路径配置错误)记 Warning, 打开失败(被占用/权限)记 Error + if (!file.exists()) { + LogManager::instance()->logWarning(QString("UF2数据类型文件不存在, 返回空表: %1").arg(filePath)); + return resultMap; + } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - LogManager::instance()->logError(QString("无法打开文件: %1").arg(filePath)); + LogManager::instance()->logError(QString("UF2数据类型文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } diff --git a/src/metadataupdate/uf2datatype.h b/src/metadataupdate/uf20/uf2datatype.h similarity index 100% rename from src/metadataupdate/uf2datatype.h rename to src/metadataupdate/uf20/uf2datatype.h diff --git a/src/metadataupdate/uf2errnumber.cpp b/src/metadataupdate/uf20/uf2errnumber.cpp similarity index 82% rename from src/metadataupdate/uf2errnumber.cpp rename to src/metadataupdate/uf20/uf2errnumber.cpp index 3bfaa3a..60216ec 100644 --- a/src/metadataupdate/uf2errnumber.cpp +++ b/src/metadataupdate/uf20/uf2errnumber.cpp @@ -1,7 +1,6 @@ #include "uf2errnumber.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "utils/Constants.h" #include #include #include @@ -20,8 +19,14 @@ QMap> Uf2ErrNumber::loadData(const QString& dirP LogManager::instance()->log(QString("加载UF2错误号文件: %1").arg(filePath)); QFile file(filePath); + // 与 uf2constants/uf2menuandfunction 统一日志分级: + // 文件缺失(路径配置错误)记 Warning, 打开失败(被占用/权限)记 Error + if (!file.exists()) { + LogManager::instance()->logWarning(QString("UF2错误号文件不存在, 返回空表: %1").arg(filePath)); + return resultMap; + } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - LogManager::instance()->logError(QString("无法打开文件: %1").arg(filePath)); + LogManager::instance()->logError(QString("UF2错误号文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } diff --git a/src/metadataupdate/uf2errnumber.h b/src/metadataupdate/uf20/uf2errnumber.h similarity index 100% rename from src/metadataupdate/uf2errnumber.h rename to src/metadataupdate/uf20/uf2errnumber.h diff --git a/src/metadataupdate/uf20/uf2interface.cpp b/src/metadataupdate/uf20/uf2interface.cpp new file mode 100644 index 0000000..f37ba0b --- /dev/null +++ b/src/metadataupdate/uf20/uf2interface.cpp @@ -0,0 +1,349 @@ +#include "uf2interface.h" +#include "utils/logmanager.h" +#include "utils/Constants.h" +#include +#include +#include +#include +#include +#include + +namespace { + // 从 "…/模块名/子目录/文件" 中提取倒数第三级目录作为模块名。 + // 旧实现直接 lastIndexOf+left/mid: 层级不足时 QString::left(-1) 返回整个字符串, + // 会把整条路径或盘符当模块名; 此处显式校验分隔符存在, 取不到返回空 + QString extractModuleName(const QString& sFilePath) + { + const int lastSlash = sFilePath.lastIndexOf('/'); + if (lastSlash == -1) return QString(); + const QString pathWithoutFile = sFilePath.left(lastSlash); + const int secondLastSlash = pathWithoutFile.lastIndexOf('/'); + if (secondLastSlash == -1) return QString(); + const QString pathWithoutLastDir = pathWithoutFile.left(secondLastSlash); + const int thirdLastSlash = pathWithoutLastDir.lastIndexOf('/'); + // 无更上层分隔符时 pathWithoutLastDir 本身就是模块名(相对路径场景), 与原语义一致 + return thirdLastSlash == -1 ? pathWithoutLastDir + : pathWithoutLastDir.mid(thirdLastSlash + 1); + } +} + +Uf2Interface::Uf2Interface(QObject *parent) : QObject(parent) +{ +} + +std::optional Uf2Interface::loadInterface(const QString& filePath) +{ + InterfaceInfo result; + result.path = filePath; + + QString sFilePath = PathUtils::normPath(filePath); + + QFileInfo fileInfo(sFilePath); + if (!fileInfo.exists()) { + LogManager::instance()->logError(QString("%1 文件不存在!").arg(sFilePath)); + return std::nullopt; // 空接口不入库, 由 scanDir 跳过并计入失败汇总 + } + + QString sCodeName = fileInfo.baseName(); + result.cname = sCodeName; + + QFile file(sFilePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + LogManager::instance()->logError(QString("%1 无法打开文件").arg(sFilePath)); + return std::nullopt; // 同上 + } + + QXmlStreamReader xml(&file); + QStack elementStack; + + while (!xml.atEnd() && !xml.hasError()) { + QXmlStreamReader::TokenType token = xml.readNext(); + + if (token == QXmlStreamReader::StartElement) { + QString elementName = xml.name().toString(); + elementStack.push(elementName); + + if (elementName == "basic") { + QXmlStreamAttributes attrs = xml.attributes(); + if (attrs.hasAttribute("englishName")) { + result.eName = attrs.value("englishName").toString(); + } + if (attrs.hasAttribute("objectId")) { + result.functionNo = attrs.value("objectId").toString(); + } + if (attrs.hasAttribute("flag")) { + result.flag = attrs.value("flag").toString(); + } + if (attrs.hasAttribute("checkLicence")) { + result.checkLicence = (attrs.value("checkLicence").toString() == "true"); + } + if (attrs.hasAttribute("returnResultSet")) { + result.returnResultSet = (attrs.value("returnResultSet").toString() == "true"); + } + if (attrs.hasAttribute("needTransMonitor")) { + result.needTransMonitor = (attrs.value("needTransMonitor").toString() == "true"); + } + } + else if (elementName == "stdFieldQuote") { + QXmlStreamAttributes attrs = xml.attributes(); + + QString parentName = elementStack.size() > 1 ? elementStack[elementStack.size() - 2] : QString(); + + FieldInfo field; + field.name = attrs.value("name").toString(); + field.flag = attrs.value("flag").toString(); + field.desc = attrs.value("comment").toString(); + + if (parentName == "import") { + result.inputFields[field.name] = field; + } else if (parentName == "export") { + result.outputFields[field.name] = field; + if (field.flag.contains("IO")) { + result.inputFields[field.name] = field; + } + } + } + else if (elementName == "variableField") { + QXmlStreamAttributes attrs = xml.attributes(); + FieldInfo field; + field.name = attrs.value("name").toString(); + field.cname = attrs.value("cname").toString(); + field.hsType = attrs.value("type").toString(); + field.desc = attrs.value("desc").toString(); + result.variableFields[field.name] = field; + } + else if (elementName == "code") { + result.code = xml.readElementText(); + // readElementText() 内部消费了 EndElement, + // 但 elementStack 中 "code" 未被弹出,需要手动同步 + if (!elementStack.isEmpty() && elementStack.top() == "code") { + elementStack.pop(); + } + if (filePath.contains("不用编译")) { + result.code.clear(); + } + } + } + else if (token == QXmlStreamReader::EndElement) { + if (!elementStack.isEmpty()) { + elementStack.pop(); + } + } + } + + // XML 格式错误时解析截断, 接口字段/代码不完整: 落日志并放弃该文件, + // 严禁残缺接口以文件名 cname 兜底入库污染 uf2_interface 表 + if (xml.hasError()) { + LogManager::instance()->logError(QString("接口 XML 解析失败, 该文件已跳过: %1 (%2)").arg(sFilePath, xml.errorString())); + file.close(); + return std::nullopt; + } + + file.close(); + return result; +} + +QString Uf2Interface::getModuleEName(const QString& moduleXmlPath) +{ + QFile file(moduleXmlPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + // 静默失败会导致该模块所有接口的 moduleEName 落库为空且无从排查, 必须告警 + LogManager::instance()->logError( + QString("module.xml 无法打开, 模块英文名将缺失: %1").arg(moduleXmlPath)); + return QString(); + } + + QXmlStreamReader xml(&file); + while (!xml.atEnd() && !xml.hasError()) { + QXmlStreamReader::TokenType token = xml.readNext(); + if (token == QXmlStreamReader::StartElement) { + QString elementName = xml.name().toString(); + if (elementName == "info") { + QXmlStreamAttributes attrs = xml.attributes(); + if (attrs.hasAttribute("ename")) { + return attrs.value("ename").toString(); + } + } + } + } + + file.close(); + return QString(); +} + +bool Uf2Interface::shouldFilter(const QString& filePath, + const QStringList& completeNames, + const QStringList& excludePathPatterns, + const QStringList& allowedExtensions) +{ + QFileInfo fileInfo(filePath); + QString fileName = fileInfo.fileName(); + + if (completeNames.contains(fileName)) { + return true; + } + + // UF2 语义: 路径子串黑名单(如 ".vbusiness"、"/数据库/"), 命中任一子串即过滤 + for (const QString& pattern : excludePathPatterns) { + if (filePath.contains(pattern, Qt::CaseInsensitive)) { + return true; + } + } + + // 扩展名白名单: UF2 接口文件内容为 XML 但扩展名是 *_design(.aservice_design/ + // .service_design/.function_design/.afunction_design/.procedure_design, 实测含 + // 元素), 而 .table_design/.view_design/.cdata/.groovy/.sequence_design + // 等均非接口。用文件名末点后的完整后缀匹配, 避免多点文件名被误切。 + // 名单为空时不过滤(退回旧版全量解析行为, 防止漏配导致 0 接口) + if (!allowedExtensions.isEmpty()) { + const int dotPos = fileName.lastIndexOf('.'); + const QString suffix = dotPos == -1 ? QString() : fileName.mid(dotPos); + if (!allowedExtensions.contains(suffix)) { + return true; + } + } + return false; +} + +ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) +{ + QMap resultMap; + const QStringList& completeNames = ctx.completeNames; + const QStringList& excludePathPatterns = ctx.excludePathPatterns; + const QStringList& allowedExtensions = ctx.allowedExtensions; + const QString& ufAcct20ProjectDir = ctx.ufAcct20ProjectDir; + const QString& uftDbSett = ctx.uftDbSett; + const QJsonObject& custJson = ctx.custJson; + + // 模块中文名 → 模块代码 内置映射(静态常量; 扫描中还会并入 cust.json moudleEName 的条目) + static const QMap kBuiltinModuleCodes = { + { QStringLiteral("转融通"), QStringLiteral("ref") }, + }; + QMap moduleCodeMap(kBuiltinModuleCodes); + + // 预提取cust.json中的模块英文名映射 + QMap custModuleENameMap; + if (!custJson.isEmpty() && custJson.contains("moudleEName")) { + QJsonObject moduleENameObj = custJson["moudleEName"].toObject(); + for (const QString& key : moduleENameObj.keys()) { + custModuleENameMap[key] = moduleENameObj[key].toString(); + } + } + + // —— 单趟目录枚举: 旧实现先全树递归找 module.xml、再全树递归找接口文件, 同一棵树 + // 被完整枚举两遍(每目录各 2 次 entryInfoList, 万级目录工程多花一整遍全树 I/O)。 + // 现一次枚举同时收集两者路径; 枚举后仍分两阶段解析, 保证 moduleCodeMap + // 完整后再赋 moduleEName(与旧实现的先后顺序一致) + QStringList moduleXmlPaths; // module.xml 候选("数据库"子树内除外) + QList> interfaceCandidates; // 接口候选 (文件路径, serverType) + + std::function collectRecursive; + collectRecursive = [&](const QString& path, const QString& serverType) { + if (path.isEmpty()) return; + if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 + QDir dir(path); + if (!dir.exists()) { + LogManager::instance()->log(QString("目录不存在: %1").arg(path)); + return; + } + // 不带通配的 Files 枚举一次拿全: module.xml 匹配与接口候选收集共用同一份结果 + const QFileInfoList fileList = dir.entryInfoList(QDir::Files); + for (const QFileInfo& fileInfo : fileList) { + const QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); + // Windows 上 QDir 名称过滤对大小写不敏感, 用 compare 保持一致 + if (fileInfo.fileName().compare(Constants::MetaPaths::ModuleXml, Qt::CaseInsensitive) == 0) { + // 与旧 module.xml 遍历一致: "数据库"子树内的 module.xml 不参与模块名映射 + if (!filePath.contains("/数据库/")) { + moduleXmlPaths.append(filePath); + } + continue; // module.xml 自身不是接口文件 + } + if (shouldFilter(filePath, completeNames, excludePathPatterns, allowedExtensions)) { + continue; + } + interfaceCandidates.append(qMakePair(filePath, serverType)); + } + // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 + const QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); + for (const QFileInfo& dirInfo : dirList) { + collectRecursive(dirInfo.absoluteFilePath(), serverType); + } + }; + + collectRecursive(dirPath, "trade"); + collectRecursive(ufAcct20ProjectDir, "acct"); + collectRecursive(uftDbSett, "dbsett"); + + // —— 阶段一: 组装 模块名→模块英文名 映射(全部 module.xml 先于接口解析, 顺序与旧实现一致) + for (const QString& moduleXmlPath : moduleXmlPaths) { + const QString modName = QFileInfo(moduleXmlPath).dir().dirName(); + QString modEName = getModuleEName(moduleXmlPath); + + // cust.json回退:当路径包含"外部接口(不用编译)"且英文名为空时 + if (moduleXmlPath.contains("外部接口(不用编译)") && modEName.trimmed().isEmpty()) { + if (custModuleENameMap.contains(modName)) { + modEName = custModuleENameMap[modName]; + } + } + + // 仅在非空时记录: 同名 module 存在多份 module.xml 时, + // 排在后且缺 ename 的(且不在"外部接口(不用编译)"cust 回退范围) + // 不会覆盖先前收集的有效英文名 + if (!modEName.isEmpty()) { + moduleCodeMap[modName] = modEName; + } + } + + // —— 阶段二: 解析候选接口文件并装配 + int failedCount = 0; // 读取/解析失败被跳过的接口文件数 + for (const auto& candidate : interfaceCandidates) { + if (isCancelRequested()) break; // 协作式取消: 外部请求后停止解析 + const QString& filePath = candidate.first; + const QString& serverType = candidate.second; + const QString modName = extractModuleName(filePath); + + std::optional loaded = loadInterface(filePath); + if (!loaded) { + // 文件不存在/无法打开/XML 解析失败: 跳过, 避免空/残接口以文件名 cname 入库 + ++failedCount; + continue; + } + InterfaceInfo interfaceInfo = *loaded; + interfaceInfo.module = modName; + interfaceInfo.moduleEName = moduleCodeMap.value(modName); + interfaceInfo.serverType = serverType; + + // cname 为空时以英文名兜底, 再以文件名兜底, 避免空键互相覆盖 + if (interfaceInfo.cname.isEmpty()) { + interfaceInfo.cname = interfaceInfo.eName.isEmpty() + ? QFileInfo(filePath).completeBaseName() : interfaceInfo.eName; + LogManager::instance()->logWarning( + QString("接口 %1 缺少中文名称属性, 以 %2 作为标识").arg(filePath, interfaceInfo.cname)); + } + const QString key = interfaceInfo.cname; + auto existing = resultMap.find(key); // find 一次取迭代器, 替代 contains+[] 双查找 + if (existing != resultMap.end()) { + if (existing->path.contains("不用编译")) { + *existing = interfaceInfo; + } else if (!filePath.contains("不用编译")) { + LogManager::instance()->logWarning(QString("存在重复接口名: %1").arg(key)); + } + } else { + resultMap[key] = interfaceInfo; + } + } + + // 失败汇总: 逐文件错误已在 loadInterface 落日志, 此处聚合数量提示扫描结果不完整 + if (failedCount > 0) { + LogManager::instance()->logError( + QString("UF2 接口扫描: 共 %1 个接口文件读取/解析失败已跳过, 结果可能不完整(详见上方错误日志)") + .arg(failedCount)); + } + + LogManager::instance()->log(QString("扫描完成,共找到 %1 个接口").arg(resultMap.size())); + + ScanResult result; + result.resultMap = resultMap; + return result; +} + diff --git a/src/metadataupdate/uf2interface.h b/src/metadataupdate/uf20/uf2interface.h similarity index 52% rename from src/metadataupdate/uf2interface.h rename to src/metadataupdate/uf20/uf2interface.h index 88d36ea..2369b5d 100644 --- a/src/metadataupdate/uf2interface.h +++ b/src/metadataupdate/uf20/uf2interface.h @@ -7,7 +7,7 @@ #include #include -#include "interface_scanner.h" +#include "../common/interface_scanner.h" class Uf2Interface : public QObject, public IInterfaceScanner { @@ -16,17 +16,15 @@ public: explicit Uf2Interface(QObject *parent = nullptr); // ---- IInterfaceScanner 实现 ---- - InterfaceInfo loadInterface(const QString& filePath) override; + std::optional loadInterface(const QString& filePath) override; ScanResult scanDir(const QString& dirPath, const ScanContext& ctx) override; - InterfaceInfo assemble(const QString& filePath, const ScanContext& ctx) override; - QList collectFiles(const QString& dirPath, const ScanContext& ctx) override; - QMap buildModuleCodeMap(const QString& dirPath, const ScanContext& ctx) override; private: QString getModuleEName(const QString& moduleXmlPath); bool shouldFilter(const QString& filePath, - const QStringList& completeNameList, - const QStringList& containNameList); + const QStringList& completeNames, + const QStringList& excludePathPatterns, + const QStringList& allowedExtensions); }; #endif // UF2INTERFACE_H diff --git a/src/metadataupdate/uf2menuandfunction.cpp b/src/metadataupdate/uf20/uf2menuandfunction.cpp similarity index 96% rename from src/metadataupdate/uf2menuandfunction.cpp rename to src/metadataupdate/uf20/uf2menuandfunction.cpp index 24af2fa..401f91e 100644 --- a/src/metadataupdate/uf2menuandfunction.cpp +++ b/src/metadataupdate/uf20/uf2menuandfunction.cpp @@ -12,7 +12,9 @@ Uf2MenuAndFunction::Uf2MenuAndFunction(QObject *parent) : QObject(parent) QMap> Uf2MenuAndFunction::loadData(const QString& dirPath) { QMap> resultMap; - QString filePath = dirPath + Constants::MetaPaths::Uf2MenuFunc; + // 与其余解析器统一: 先 normPath 规范分隔符, 避免混合 '/' '\\' 拼接 + QString filePath = PathUtils::normPath(dirPath); + filePath += Constants::MetaPaths::Uf2MenuFunc; QFile file(filePath); if (!file.exists()) { // 文件缺失是路径配置错误/工程不完整的信号, 静默返回空表会让功能查询 diff --git a/src/metadataupdate/uf2menuandfunction.h b/src/metadataupdate/uf20/uf2menuandfunction.h similarity index 100% rename from src/metadataupdate/uf2menuandfunction.h rename to src/metadataupdate/uf20/uf2menuandfunction.h diff --git a/src/metadataupdate/uf2module.cpp b/src/metadataupdate/uf20/uf2module.cpp similarity index 84% rename from src/metadataupdate/uf2module.cpp rename to src/metadataupdate/uf20/uf2module.cpp index 25270f0..b733ec5 100644 --- a/src/metadataupdate/uf2module.cpp +++ b/src/metadataupdate/uf20/uf2module.cpp @@ -1,5 +1,6 @@ #include "uf2module.h" #include "utils/logmanager.h" +#include "utils/Constants.h" #include #include #include @@ -11,15 +12,21 @@ Uf2Module::Uf2Module(QObject *parent) : QObject(parent) void Uf2Module::scanDir(const QString& dirPath) { + // 协作式取消: 大目录树递归收集可能持续数十秒, 中断后不再继续 + if (isCancelRequested()) { + return; + } + QDir dir(dirPath); if (!dir.exists()) { return; } - QFileInfoList entries = dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); + // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描(与 uf2table/uft3table 对齐) + QFileInfoList entries = dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot | QDir::NoSymLinks); for (const QFileInfo& info : entries) { if (info.isDir()) { scanDir(info.filePath()); - } else if (info.fileName().compare("module.xml", Qt::CaseInsensitive) == 0) { + } else if (info.fileName().compare(Constants::MetaPaths::ModuleXml, Qt::CaseInsensitive) == 0) { // 与 Python 端对齐: 跳过 "数据库" 子目录下的 module.xml if (info.path().replace('\\', '/').contains("/数据库/", Qt::CaseInsensitive)) { continue; diff --git a/src/metadataupdate/uf20/uf2module.h b/src/metadataupdate/uf20/uf2module.h new file mode 100644 index 0000000..235e718 --- /dev/null +++ b/src/metadataupdate/uf20/uf2module.h @@ -0,0 +1,31 @@ +#ifndef UF2MODULE_H +#define UF2MODULE_H + +#include +#include +#include +#include + +// 解析 UF2.0 模块: 工程各目录下 module.xml +// key = ename, data = {cname, path} +class Uf2Module : public QObject +{ + Q_OBJECT +public: + explicit Uf2Module(QObject *parent = nullptr); + + // 设置外部取消标志(指向 MetadataProcessor 的原子标志, 生命周期由调用方保证): + // 全工程树递归扫描可能很大, 无检查点时 requestCancel 后扫描仍会跑完 + void setCancelFlag(std::atomic_bool* flag) { m_cancelFlag = flag; } + + QMap> loadData(const QString& dirPath); + +private: + // 递归扫描循环中的协作式取消检查点 + bool isCancelRequested() const { return m_cancelFlag && m_cancelFlag->load(std::memory_order_relaxed); } + void scanDir(const QString& dirPath); + QMap> m_resultMap; + std::atomic_bool* m_cancelFlag = nullptr; // 外部取消标志, 非拥有指针 +}; + +#endif diff --git a/src/metadataupdate/uf2stdfields.cpp b/src/metadataupdate/uf20/uf2stdfields.cpp similarity index 78% rename from src/metadataupdate/uf2stdfields.cpp rename to src/metadataupdate/uf20/uf2stdfields.cpp index 56302d2..a255d51 100644 --- a/src/metadataupdate/uf2stdfields.cpp +++ b/src/metadataupdate/uf20/uf2stdfields.cpp @@ -1,7 +1,6 @@ #include "uf2stdfields.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "utils/Constants.h" #include #include #include @@ -22,8 +21,14 @@ QMap> Uf2StdFields::loadData( LogManager::instance()->log(QString("加载UF2标准字段文件: %1").arg(filePath)); QFile file(filePath); + // 与 uf2constants/uf2menuandfunction 统一日志分级: + // 文件缺失(路径配置错误)记 Warning, 打开失败(被占用/权限)记 Error + if (!file.exists()) { + LogManager::instance()->logWarning(QString("UF2标准字段文件不存在, 返回空表: %1").arg(filePath)); + return resultMap; + } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - LogManager::instance()->logError(QString("无法打开文件: %1").arg(filePath)); + LogManager::instance()->logError(QString("UF2标准字段文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } @@ -49,9 +54,11 @@ QMap> Uf2StdFields::loadData( QString sCtype = ""; QString sOType = ""; - if (uf2datatypeMap.contains(sType)) { - sCtype = uf2datatypeMap[sType].value("CType"); - sOType = uf2datatypeMap[sType].value("OType"); + // value() 一次查找, 替代 contains+operator[] 双重哈希 + const auto typeIt = uf2datatypeMap.constFind(sType); + if (typeIt != uf2datatypeMap.constEnd()) { + sCtype = typeIt->value("CType"); + sOType = typeIt->value("OType"); } QMap fieldInfo; diff --git a/src/metadataupdate/uf2stdfields.h b/src/metadataupdate/uf20/uf2stdfields.h similarity index 100% rename from src/metadataupdate/uf2stdfields.h rename to src/metadataupdate/uf20/uf2stdfields.h diff --git a/src/metadataupdate/uf2table.cpp b/src/metadataupdate/uf20/uf2table.cpp similarity index 59% rename from src/metadataupdate/uf2table.cpp rename to src/metadataupdate/uf20/uf2table.cpp index 8f0c52f..60aa5b0 100644 --- a/src/metadataupdate/uf2table.cpp +++ b/src/metadataupdate/uf20/uf2table.cpp @@ -1,9 +1,10 @@ #include "uf2table.h" #include "utils/logmanager.h" +#include "utils/Constants.h" #include #include #include -#include +#include #include #include #include @@ -36,6 +37,10 @@ QString schemaToUser(const QString& schema) void Uf2Table::scanDir(const QString& dirPath) { + // 协作式取消: 每层递归入口检查, requestCancel 后整棵子树直接放弃 + if (isCancelRequested()) { + return; + } // dirPath 已是“数据库”目录(或其子目录), 直接遍历, 不再重复拼 “/数据库” QDir dbDir(dirPath); if (!dbDir.exists()) { @@ -47,7 +52,7 @@ void Uf2Table::scanDir(const QString& dirPath) if (info.isDir()) { scanDir(info.filePath()); } else if (info.fileName().endsWith(".table_design", Qt::CaseInsensitive) && - info.fileName().compare("module.xml", Qt::CaseInsensitive) != 0) { + info.fileName().compare(Constants::MetaPaths::ModuleXml, Qt::CaseInsensitive) != 0) { QString tableCName = info.completeBaseName(); // 文件名去掉扩展名(多点文件名用首点前会截断中间内容) QJsonObject obj = loadTable(info.filePath(), tableCName); QString tableName = obj.value("name").toString(); @@ -55,13 +60,16 @@ void Uf2Table::scanDir(const QString& dirPath) LogManager::instance()->logWarning( QString("表结构缺少 tableName 属性, 已跳过: %1").arg(info.filePath())); } else { - if (m_resultMap.contains(tableName)) { + auto existing = m_resultMap.find(tableName); // find 一次取迭代器, 替代 contains+value+[] 多次查找 + if (existing != m_resultMap.end()) { LogManager::instance()->logWarning( QString("发现重名表 %1: %2 将覆盖 %3") .arg(tableName, info.filePath(), - m_resultMap.value(tableName).value("cname").toString())); + existing->value("cname").toString())); + *existing = obj; + } else { + m_resultMap[tableName] = obj; } - m_resultMap[tableName] = obj; } } } @@ -138,31 +146,55 @@ QMap Uf2Table::loadData(const QString& dirPath, const QStr scanDir(dbDir.path()); } if (!settDirPath.isEmpty() && QDir::isAbsolutePath(settDirPath)) { - // sett 为整个工程: 仅取路径含 "/数据库/" 的文件(对齐 Python 行为), - // 否则"数据库"目录之外的 .table_design 也会被收录并可能覆盖正式表 - QDirIterator it(settDirPath, QStringList() << "*.table_design", - QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories); - while (it.hasNext()) { - const QString filePath = it.next(); - if (!filePath.contains("/数据库/")) { - continue; + // sett 为整个工程: 仅取 "数据库" 子树内的 .table_design(对齐 Python 行为), + // 否则"数据库"目录之外的 .table_design 也会被收录并可能覆盖正式表。 + // 手写递归 + 剪枝: 旧 QDirIterator 无差别全树枚举文件后靠 contains("/数据库/") + // 事后丢弃, "数据库"之外的文件枚举全部白做; 现仅在定位到"数据库"子树后才 + // 枚举文件, 其余子树只做目录下钻, sett 树的枚举开销与其"数据库"体积成正比 + const QString dbSeg = QStringLiteral("数据库"); + std::function scanSett; + scanSett = [&](const QString& path, bool inDb) { + if (isCancelRequested()) return; // 协作式取消: 静默停止下钻(文件循环另有告警) + QDir dir(path); + if (dir.dirName() == dbSeg) { + inDb = true; // settDirPath 自身即"数据库"目录的边界情况 } - QString tableCName = QFileInfo(filePath).completeBaseName(); - QJsonObject obj = loadTable(filePath, tableCName); - QString tableName = obj.value("name").toString(); - if (tableName.isEmpty()) { - LogManager::instance()->logWarning( - QString("表结构缺少 tableName 属性, 已跳过: %1").arg(filePath)); - } else { - if (m_resultMap.contains(tableName)) { - LogManager::instance()->logWarning( - QString("发现重名表 %1: %2 将覆盖 %3") - .arg(tableName, filePath, - m_resultMap.value(tableName).value("cname").toString())); + if (inDb) { + const QFileInfoList files = dir.entryInfoList( + QStringList() << "*.table_design", QDir::Files | QDir::NoSymLinks); + for (const QFileInfo& fileInfo : files) { + // 协作式取消: 命中时保留已收集的部分结果, 告警仅落一次 + if (isCancelRequested()) { + LogManager::instance()->logWarning("UF2.0 表结构解析已取消"); + return; + } + const QString filePath = fileInfo.absoluteFilePath(); + QJsonObject obj = loadTable(filePath, fileInfo.completeBaseName()); + QString tableName = obj.value("name").toString(); + if (tableName.isEmpty()) { + LogManager::instance()->logWarning( + QString("表结构缺少 tableName 属性, 已跳过: %1").arg(filePath)); + } else { + auto existing = m_resultMap.find(tableName); // find 一次取迭代器, 替代 contains+value+[] 多次查找 + if (existing != m_resultMap.end()) { + LogManager::instance()->logWarning( + QString("发现重名表 %1: %2 将覆盖 %3") + .arg(tableName, filePath, + existing->value("cname").toString())); + *existing = obj; + } else { + m_resultMap[tableName] = obj; + } + } } - m_resultMap[tableName] = obj; } - } + // 未在"数据库"子树时仍需下钻寻找名为"数据库"的子目录; 命中后整树收集 + const QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); + for (const QFileInfo& dirInfo : dirList) { + scanSett(dirInfo.absoluteFilePath(), inDb || dirInfo.fileName() == dbSeg); + } + }; + scanSett(settDirPath, false); } return m_resultMap; } diff --git a/src/metadataupdate/uf2table.h b/src/metadataupdate/uf20/uf2table.h similarity index 64% rename from src/metadataupdate/uf2table.h rename to src/metadataupdate/uf20/uf2table.h index f6f8be4..129fac1 100644 --- a/src/metadataupdate/uf2table.h +++ b/src/metadataupdate/uf20/uf2table.h @@ -5,6 +5,7 @@ #include #include #include +#include // 解析 UF2.0 表结构: 各工程目录下 数据库/*.table_design // key = tableName, data = {name, cname, filedList, indexList, user} @@ -14,12 +15,17 @@ class Uf2Table : public QObject public: explicit Uf2Table(QObject *parent = nullptr); + // 注入协作式取消标志(非拥有指针): 大目录树递归扫描期间 requestCancel 后可提前中断 + void setCancelFlag(std::atomic_bool *flag) { m_cancelFlag = flag; } + bool isCancelRequested() const { return m_cancelFlag && m_cancelFlag->load(std::memory_order_relaxed); } + QMap loadData(const QString& dirPath, const QString& settDirPath = QString()); private: void scanDir(const QString& dirPath); QJsonObject loadTable(const QString& filePath, const QString& tableCName); QMap m_resultMap; + std::atomic_bool *m_cancelFlag = nullptr; }; #endif diff --git a/src/metadataupdate/uf2interface.cpp b/src/metadataupdate/uf2interface.cpp deleted file mode 100644 index 6d01586..0000000 --- a/src/metadataupdate/uf2interface.cpp +++ /dev/null @@ -1,415 +0,0 @@ -#include "uf2interface.h" -#include "utils/logmanager.h" -#include "utils/Constants.h" -#include -#include -#include -#include -#include -#include -#include - -namespace { - // 从 "…/模块名/子目录/文件" 中提取倒数第三级目录作为模块名。 - // 旧实现直接 lastIndexOf+left/mid: 层级不足时 QString::left(-1) 返回整个字符串, - // 会把整条路径或盘符当模块名; 此处显式校验分隔符存在, 取不到返回空 - QString extractModuleName(const QString& sFilePath) - { - const int lastSlash = sFilePath.lastIndexOf('/'); - if (lastSlash == -1) return QString(); - const QString pathWithoutFile = sFilePath.left(lastSlash); - const int secondLastSlash = pathWithoutFile.lastIndexOf('/'); - if (secondLastSlash == -1) return QString(); - const QString pathWithoutLastDir = pathWithoutFile.left(secondLastSlash); - const int thirdLastSlash = pathWithoutLastDir.lastIndexOf('/'); - // 无更上层分隔符时 pathWithoutLastDir 本身就是模块名(相对路径场景), 与原语义一致 - return thirdLastSlash == -1 ? pathWithoutLastDir - : pathWithoutLastDir.mid(thirdLastSlash + 1); - } -} - -Uf2Interface::Uf2Interface(QObject *parent) : QObject(parent) -{ -} - -InterfaceInfo Uf2Interface::loadInterface(const QString& filePath) -{ - InterfaceInfo result; - result.path = filePath; - - QString sFilePath = PathUtils::normPath(filePath); - - QFileInfo fileInfo(sFilePath); - if (!fileInfo.exists()) { - LogManager::instance()->logError(QString("%1 文件不存在!").arg(sFilePath)); - return result; - } - - QString sCodeName = fileInfo.baseName(); - result.cname = sCodeName; - - QFile file(sFilePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - LogManager::instance()->logError(QString("%1 无法打开文件").arg(sFilePath)); - return result; - } - - QXmlStreamReader xml(&file); - QStack elementStack; - - while (!xml.atEnd() && !xml.hasError()) { - QXmlStreamReader::TokenType token = xml.readNext(); - - if (token == QXmlStreamReader::StartElement) { - QString elementName = xml.name().toString(); - elementStack.push(elementName); - - if (elementName == "basic") { - QXmlStreamAttributes attrs = xml.attributes(); - if (attrs.hasAttribute("englishName")) { - result.eName = attrs.value("englishName").toString(); - } - if (attrs.hasAttribute("objectId")) { - result.functionNo = attrs.value("objectId").toString(); - } - if (attrs.hasAttribute("flag")) { - result.flag = attrs.value("flag").toString(); - } - if (attrs.hasAttribute("checkLicence")) { - result.checkLicence = (attrs.value("checkLicence").toString() == "true"); - } - if (attrs.hasAttribute("returnResultSet")) { - result.returnResultSet = (attrs.value("returnResultSet").toString() == "true"); - } - if (attrs.hasAttribute("needTransMonitor")) { - result.needTransMonitor = (attrs.value("needTransMonitor").toString() == "true"); - } - } - else if (elementName == "stdFieldQuote") { - QXmlStreamAttributes attrs = xml.attributes(); - - QString parentName = elementStack.size() > 1 ? elementStack[elementStack.size() - 2] : QString(); - - FieldInfo field; - field.name = attrs.value("name").toString(); - field.flag = attrs.value("flag").toString(); - field.desc = attrs.value("comment").toString(); - - if (parentName == "import") { - result.inputFields[field.name] = field; - } else if (parentName == "export") { - result.outputFields[field.name] = field; - if (field.flag.contains("IO")) { - result.inputFields[field.name] = field; - } - } - } - else if (elementName == "variableField") { - QXmlStreamAttributes attrs = xml.attributes(); - FieldInfo field; - field.name = attrs.value("name").toString(); - field.cname = attrs.value("cname").toString(); - field.hsType = attrs.value("type").toString(); - field.desc = attrs.value("desc").toString(); - result.variableFields[field.name] = field; - } - else if (elementName == "code") { - result.code = xml.readElementText(); - // readElementText() 内部消费了 EndElement, - // 但 elementStack 中 "code" 未被弹出,需要手动同步 - if (!elementStack.isEmpty() && elementStack.top() == "code") { - elementStack.pop(); - } - if (filePath.contains("不用编译")) { - result.code.clear(); - } - } - } - else if (token == QXmlStreamReader::EndElement) { - if (!elementStack.isEmpty()) { - elementStack.pop(); - } - } - } - - // XML 格式错误时循环静默截断, 接口字段/代码不完整, 必须落日志 - if (xml.hasError()) { - LogManager::instance()->logError(QString("接口 XML 解析失败: %1 (%2)").arg(sFilePath, xml.errorString())); - } - - file.close(); - return result; -} - -QString Uf2Interface::getModuleEName(const QString& moduleXmlPath) -{ - QFile file(moduleXmlPath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QString(); - } - - QXmlStreamReader xml(&file); - while (!xml.atEnd() && !xml.hasError()) { - QXmlStreamReader::TokenType token = xml.readNext(); - if (token == QXmlStreamReader::StartElement) { - QString elementName = xml.name().toString(); - if (elementName == "info") { - QXmlStreamAttributes attrs = xml.attributes(); - if (attrs.hasAttribute("ename")) { - return attrs.value("ename").toString(); - } - } - } - } - - file.close(); - return QString(); -} - -bool Uf2Interface::shouldFilter(const QString& filePath, - const QStringList& completeNameList, - const QStringList& containNameList) -{ - QFileInfo fileInfo(filePath); - QString fileName = fileInfo.fileName(); - - if (completeNameList.contains(fileName)) { - return true; - } - - for (const QString& name : containNameList) { - if (filePath.contains(name, Qt::CaseInsensitive)) { - return true; - } - } - return false; -} - -ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) -{ - QMap resultMap; - QStringList completeNameList = ctx.fileFilterDict.value("completeNameList"); - QStringList containNameList = ctx.fileFilterDict.value("containNameList"); - const QString& ufAcct20ProjectDir = ctx.ufAcct20ProjectDir; - const QString& uftDbSett = ctx.uftDbSett; - const QJsonObject& custJson = ctx.custJson; - - QMap moduleCodeMap; - moduleCodeMap["转融通"] = "ref"; - - // 预提取cust.json中的模块英文名映射 - QMap custMoudleENameMap; - if (!custJson.isEmpty() && custJson.contains("moudleEName")) { - QJsonObject moudleENameObj = custJson["moudleEName"].toObject(); - for (const QString& key : moudleENameObj.keys()) { - custMoudleENameMap[key] = moudleENameObj[key].toString(); - } - } - - std::function scanModuleXmlRecursive; - scanModuleXmlRecursive = [&](const QString& path) { - if (path.isEmpty()) return; - if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 - QDir dir(path); - QStringList filters; - filters << "module.xml"; - - QFileInfoList fileList = dir.entryInfoList(filters, QDir::Files); - for (const QFileInfo& fileInfo : fileList) { - QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); - - if (filePath.contains("/数据库/")) { - continue; - } - - QString modName = fileInfo.dir().dirName(); - QString modEName = getModuleEName(filePath); - - // cust.json回退:当路径包含"外部接口(不用编译)"且英文名为空时 - if (filePath.contains("外部接口(不用编译)") && modEName.trimmed().isEmpty()) { - if (custMoudleENameMap.contains(modName)) { - modEName = custMoudleENameMap[modName]; - } - } - - moduleCodeMap[modName] = modEName; - } - - // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); - for (const QFileInfo& dirInfo : dirList) { - scanModuleXmlRecursive(dirInfo.absoluteFilePath()); - } - }; - - auto scanModuleXml = [&](const QString& path) { - scanModuleXmlRecursive(path); - }; - - scanModuleXml(dirPath); - scanModuleXml(ufAcct20ProjectDir); - scanModuleXml(uftDbSett); - - std::function scanInterfaceFilesRecursive; - scanInterfaceFilesRecursive = [&](const QString& path, const QString& serverType) { - if (path.isEmpty()) { - LogManager::instance()->log("路径为空,跳过"); - return; - } - if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 - QDir dir(path); - if (!dir.exists()) { - LogManager::instance()->log(QString("目录不存在: %1").arg(path)); - return; - } - QStringList filters; - filters << "*.*"; - - QFileInfoList fileList = dir.entryInfoList(filters, QDir::Files); - for (const QFileInfo& fileInfo : fileList) { - QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); - if (shouldFilter(filePath, completeNameList, containNameList)) { - continue; - } - - QString sFilePath = filePath; - QString modName = extractModuleName(sFilePath); - - InterfaceInfo interfaceInfo = loadInterface(filePath); - interfaceInfo.moudle = modName; - QString moudleEName = moduleCodeMap.value(modName); - interfaceInfo.moudleEName = moudleEName; - interfaceInfo.serverType = serverType; - - // cname 为空时以英文名兜底, 再以文件名兜底, 避免空键互相覆盖 - if (interfaceInfo.cname.isEmpty()) { - interfaceInfo.cname = interfaceInfo.eName.isEmpty() - ? fileInfo.completeBaseName() : interfaceInfo.eName; - LogManager::instance()->logWarning( - QString("接口 %1 缺少中文名称属性, 以 %2 作为标识").arg(filePath, interfaceInfo.cname)); - } - QString key = interfaceInfo.cname; - if (resultMap.contains(key)) { - QString existingPath = resultMap[key].path; - if (existingPath.contains("不用编译")) { - resultMap[key] = interfaceInfo; - } else if (!filePath.contains("不用编译")) { - LogManager::instance()->logWarning(QString("存在重复接口名: %1").arg(key)); - } - } else { - resultMap[key] = interfaceInfo; - } - } - - // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); - for (const QFileInfo& dirInfo : dirList) { - QString subDirPath = PathUtils::normPath(dirInfo.absoluteFilePath()); - scanInterfaceFilesRecursive(subDirPath, serverType); - } - }; - - auto scanInterfaceFiles = [&](const QString& path, const QString& serverType) { - scanInterfaceFilesRecursive(path, serverType); - }; - - scanInterfaceFiles(dirPath, "trade"); - scanInterfaceFiles(ufAcct20ProjectDir, "acct"); - scanInterfaceFiles(uftDbSett, "dbsett"); - - LogManager::instance()->log(QString("扫描完成,共找到 %1 个接口").arg(resultMap.size())); - - ScanResult result; - result.resultMap = resultMap; - return result; -} - -QMap Uf2Interface::buildModuleCodeMap(const QString& dirPath, const ScanContext& ctx) -{ - QMap moduleCodeMap; - moduleCodeMap["转融通"] = "ref"; - const QString& ufAcct20ProjectDir = ctx.ufAcct20ProjectDir; - const QString& uftDbSett = ctx.uftDbSett; - const QJsonObject& custJson = ctx.custJson; - - // 预提取cust.json中的模块英文名映射 - QMap custMoudleENameMap; - if (!custJson.isEmpty() && custJson.contains("moudleEName")) { - QJsonObject moudleENameObj = custJson["moudleEName"].toObject(); - for (const QString& key : moudleENameObj.keys()) { - custMoudleENameMap[key] = moudleENameObj[key].toString(); - } - } - - std::function scanModuleXmlRecursive = [&](const QString& path) { - if (path.isEmpty()) return; - if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 - QDir dir(path); - QStringList filters; - filters << "module.xml"; - QFileInfoList fileList = dir.entryInfoList(filters, QDir::Files); - for (const QFileInfo& fileInfo : fileList) { - QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); - if (filePath.contains("/数据库/")) continue; - QString modName = fileInfo.dir().dirName(); - QString modEName = getModuleEName(filePath); - if (filePath.contains("外部接口(不用编译)") && modEName.trimmed().isEmpty()) { - if (custMoudleENameMap.contains(modName)) modEName = custMoudleENameMap[modName]; - } - moduleCodeMap[modName] = modEName; - } - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); - for (const QFileInfo& dirInfo : dirList) scanModuleXmlRecursive(dirInfo.absoluteFilePath()); - }; - scanModuleXmlRecursive(dirPath); - scanModuleXmlRecursive(ufAcct20ProjectDir); - scanModuleXmlRecursive(uftDbSett); - return moduleCodeMap; -} - -InterfaceInfo Uf2Interface::assemble(const QString& filePath, const ScanContext& ctx) -{ - QString sFilePath = PathUtils::normPath(filePath); - QString modName = extractModuleName(sFilePath); - const QString& serverType = ctx.serverType; - const QMap& moduleCodeMap = ctx.moduleCodeMap; - - InterfaceInfo interfaceInfo = loadInterface(filePath); - interfaceInfo.moudle = modName; - interfaceInfo.moudleEName = moduleCodeMap.value(modName); - interfaceInfo.serverType = serverType; - return interfaceInfo; -} - -QList Uf2Interface::collectFiles(const QString& dirPath, const ScanContext& ctx) -{ - QList result; - QStringList completeNameList = ctx.fileFilterDict.value("completeNameList"); - QStringList containNameList = ctx.fileFilterDict.value("containNameList"); - - std::function scanInterfaceFilesRecursive = - [&](const QString& path, const QString& st) { - if (path.isEmpty()) return; - if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 - QDir dir(path); - if (!dir.exists()) return; - QFileInfoList fileList = dir.entryInfoList(QStringList() << "*.*", QDir::Files); - for (const QFileInfo& fileInfo : fileList) { - QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); - if (shouldFilter(filePath, completeNameList, containNameList)) continue; - FileEntry entry; - entry.path = filePath; - entry.serverType = st; - entry.mtime = fileInfo.lastModified().toMSecsSinceEpoch(); - result.append(entry); - } - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); - for (const QFileInfo& dirInfo : dirList) scanInterfaceFilesRecursive(dirInfo.absoluteFilePath(), st); - }; - scanInterfaceFilesRecursive(dirPath, "trade"); - if (!ctx.ufAcct20ProjectDir.isEmpty()) - scanInterfaceFilesRecursive(ctx.ufAcct20ProjectDir, "acct"); - if (!ctx.uftDbSett.isEmpty()) - scanInterfaceFilesRecursive(ctx.uftDbSett, "dbsett"); - return result; -} diff --git a/src/metadataupdate/uf2module.h b/src/metadataupdate/uf2module.h deleted file mode 100644 index aa63c2a..0000000 --- a/src/metadataupdate/uf2module.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef UF2MODULE_H -#define UF2MODULE_H - -#include -#include -#include - -// 解析 UF2.0 模块: 工程各目录下 module.xml -// key = ename, data = {cname, path} -class Uf2Module : public QObject -{ - Q_OBJECT -public: - explicit Uf2Module(QObject *parent = nullptr); - - QMap> loadData(const QString& dirPath); - -private: - void scanDir(const QString& dirPath); - QMap> m_resultMap; -}; - -#endif diff --git a/src/metadataupdate/uft3datatype.cpp b/src/metadataupdate/uft30/uft3datatype.cpp similarity index 69% rename from src/metadataupdate/uft3datatype.cpp rename to src/metadataupdate/uft30/uft3datatype.cpp index c0a35d3..34fd940 100644 --- a/src/metadataupdate/uft3datatype.cpp +++ b/src/metadataupdate/uft30/uft3datatype.cpp @@ -1,7 +1,6 @@ #include "uft3datatype.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "utils/Constants.h" #include #include #include @@ -20,8 +19,14 @@ QMap> Uft3DataType::loadData(const QString& dirP LogManager::instance()->log(QString("加载UFT3数据类型文件: %1").arg(filePath)); QFile file(filePath); + // 与 uf2constants/uf2menuandfunction 统一日志分级: + // 文件缺失(路径配置错误)记 Warning, 打开失败(被占用/权限)记 Error + if (!file.exists()) { + LogManager::instance()->logWarning(QString("UFT3数据类型文件不存在, 返回空表: %1").arg(filePath)); + return resultMap; + } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - LogManager::instance()->logError(QString("无法打开文件: %1").arg(filePath)); + LogManager::instance()->logError(QString("UFT3数据类型文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } @@ -41,7 +46,17 @@ QMap> Uft3DataType::loadData(const QString& dirP QString uftLen = "0"; if (uftValue.toLower() == "string") { - uftLen = attrs.value("length").toString(); + uftLen = attrs.value("length").toString().trimmed(); + // length 属性缺失/为空/非数字时回退默认 1 并告警: + // 原 "char[]" 脏类型会被标准字段映射引用落库, 下游无法识别 + bool lenOk = false; + const int len = uftLen.toInt(&lenOk); + if (!lenOk || len <= 0) { + LogManager::instance()->logWarning( + QString("UFT3数据类型 %1 的 string length 属性无效(\"%2\"), 回退默认 1") + .arg(hsType, uftLen)); + uftLen = "1"; + } uftValue = "char[" + uftLen + "]"; } else if (uftValue.toLower() == "int32") { diff --git a/src/metadataupdate/uft3datatype.h b/src/metadataupdate/uft30/uft3datatype.h similarity index 100% rename from src/metadataupdate/uft3datatype.h rename to src/metadataupdate/uft30/uft3datatype.h diff --git a/src/metadataupdate/uft3interface.cpp b/src/metadataupdate/uft30/uft3interface.cpp similarity index 73% rename from src/metadataupdate/uft3interface.cpp rename to src/metadataupdate/uft30/uft3interface.cpp index 31b0b2d..f8c743b 100644 --- a/src/metadataupdate/uft3interface.cpp +++ b/src/metadataupdate/uft30/uft3interface.cpp @@ -1,7 +1,6 @@ #include "uft3interface.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "filedb.h" #include #include #include @@ -34,23 +33,23 @@ Uft3Interface::Uft3Interface(QObject *parent) : QObject(parent) { } -InterfaceInfo Uft3Interface::loadInterface(const QString& filePath) +std::optional Uft3Interface::loadInterface(const QString& filePath) { InterfaceInfo result; result.path = filePath; - + QString sFilePath = PathUtils::normPath(filePath); - + QFileInfo fileInfo(sFilePath); if (!fileInfo.exists()) { LogManager::instance()->logError(QString("文件不存在: %1").arg(sFilePath)); - return result; + return std::nullopt; // 空接口不入库, 由 scanDirRecursive 跳过并计入失败汇总 } - + QFile file(sFilePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { LogManager::instance()->logError(QString("无法打开文件: %1").arg(sFilePath)); - return result; + return std::nullopt; // 同上 } // 头两行是含中文的属性注释行: QTextStream 默认按系统 locale 解码, @@ -144,19 +143,17 @@ InterfaceInfo Uft3Interface::loadInterface(const QString& filePath) } bool Uft3Interface::shouldFilter(const QString& filePath, - const QString& dirPath, - const QStringList& completeNameList, - const QStringList& containNameList, + const QStringList& completeNames, + const QStringList& allowedExtensions, const QString& moduleGenerationCheck) { QFileInfo fileInfo(filePath); QString fileName = fileInfo.fileName(); - QString relPath = filePath.mid(dirPath.length()); - - if (completeNameList.contains(fileName)) { + + if (completeNames.contains(fileName)) { return true; } - + // 只取文件名自身的扩展名参与保留名单匹配: 原 relPath 首点截断会把含点目录名 // (v2.1/ 2024.06/)或多点文件名(func.v2.uftfunction)的中间内容一并算进 suffix, // 与 .uftfunction 等整串精确匹配失败后被静默过滤(无日志), 导致接口漏扫 @@ -165,8 +162,9 @@ bool Uft3Interface::shouldFilter(const QString& filePath, if (dotPos != -1) { suffix = fileName.mid(dotPos); } - - if (!containNameList.contains(suffix)) { + + // UFT3 语义: 扩展名白名单, 后缀不在名单内即过滤(与 UF2 的路径子串黑名单语义相反) + if (!allowedExtensions.contains(suffix)) { return true; } @@ -180,30 +178,37 @@ bool Uft3Interface::shouldFilter(const QString& filePath, } void Uft3Interface::scanDirRecursive(const QString& dirPath, - const QStringList& completeNameList, - const QStringList& containNameList, + const QStringList& completeNames, + const QStringList& allowedExtensions, const QString& moduleGenerationCheck, QMap& resultMap, - QMap& transCodeMap) + QMap& transCodeMap, + int& failedCount) { if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 QDir dir(dirPath); QStringList filters; filters << "*.*"; - + QFileInfoList fileList = dir.entryInfoList(filters, QDir::Files); for (const QFileInfo& fileInfo : fileList) { QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); - if (shouldFilter(filePath, dirPath, completeNameList, containNameList, moduleGenerationCheck)) { + if (shouldFilter(filePath, completeNames, allowedExtensions, moduleGenerationCheck)) { continue; } bool isTransCode = PathUtils::isTransCodePath(filePath); - + QString modName = fileInfo.dir().dirName(); - InterfaceInfo interfaceInfo = loadInterface(filePath); - interfaceInfo.moudle = modName; + std::optional loaded = loadInterface(filePath); + if (!loaded) { + // 文件不存在/无法打开: 跳过, 避免空接口覆盖同名有效接口或以空键入库 + ++failedCount; + continue; + } + InterfaceInfo interfaceInfo = *loaded; + interfaceInfo.module = modName; // cname 为空(接口头属性行缺失/格式变化)时以英文名兜底, 再以文件名兜底, // 避免多个解析失败的接口全部坍缩进空键互相覆盖只剩一个 @@ -215,19 +220,22 @@ void Uft3Interface::scanDirRecursive(const QString& dirPath, } QMap& target = isTransCode ? transCodeMap : resultMap; - if (target.contains(interfaceInfo.cname)) { + auto existing = target.find(interfaceInfo.cname); // find 一次取迭代器, 替代 contains+value+[] 多次查找 + if (existing != target.end()) { LogManager::instance()->logWarning( QString("发现重名接口 %1: %2 将覆盖 %3") - .arg(interfaceInfo.cname, filePath, target.value(interfaceInfo.cname).path)); + .arg(interfaceInfo.cname, filePath, existing->path)); + *existing = interfaceInfo; + } else { + target[interfaceInfo.cname] = interfaceInfo; } - target[interfaceInfo.cname] = interfaceInfo; } // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); for (const QFileInfo& dirInfo : dirList) { - scanDirRecursive(dirInfo.absoluteFilePath(), completeNameList, - containNameList, moduleGenerationCheck, resultMap, transCodeMap); + scanDirRecursive(dirInfo.absoluteFilePath(), completeNames, + allowedExtensions, moduleGenerationCheck, resultMap, transCodeMap, failedCount); } } @@ -236,11 +244,20 @@ ScanResult Uft3Interface::scanDir(const QString& dirPath, const ScanContext& ctx QMap resultMap; QMap transCodeMap; - QStringList completeNameList = ctx.fileFilterDict.value("completeNameList"); - QStringList containNameList = ctx.fileFilterDict.value("containNameList"); + const QStringList& completeNames = ctx.completeNames; + const QStringList& allowedExtensions = ctx.allowedExtensions; const QString& moduleGenerationCheck = ctx.moduleGenerationCheck; - scanDirRecursive(dirPath, completeNameList, containNameList, moduleGenerationCheck, resultMap, transCodeMap); + int failedCount = 0; // 读取失败(文件不存在/无法打开)被跳过的接口文件数 + scanDirRecursive(dirPath, completeNames, allowedExtensions, moduleGenerationCheck, + resultMap, transCodeMap, failedCount); + + // 失败汇总: 逐文件错误已在 loadInterface 落日志, 此处聚合数量提示扫描结果不完整 + if (failedCount > 0) { + LogManager::instance()->logError( + QString("UFT3 接口扫描: 共 %1 个接口文件读取失败已跳过, 结果可能不完整(详见上方错误日志)") + .arg(failedCount)); + } ScanResult result; result.resultMap = resultMap; @@ -248,53 +265,3 @@ ScanResult Uft3Interface::scanDir(const QString& dirPath, const ScanContext& ctx return result; } -InterfaceInfo Uft3Interface::assemble(const QString& filePath, const ScanContext&) -{ - QFileInfo fileInfo(filePath); - fileInfo.setFile(fileInfo.absoluteFilePath()); // 标准化路径 - QString modName = fileInfo.dir().dirName(); - InterfaceInfo interfaceInfo = loadInterface(filePath); - interfaceInfo.moudle = modName; - return interfaceInfo; -} - -QList Uft3Interface::collectFiles(const QString& dirPath, const ScanContext& ctx) -{ - QList result; - QStringList completeNameList = ctx.fileFilterDict.value("completeNameList"); - QStringList containNameList = ctx.fileFilterDict.value("containNameList"); - const QString& moduleGenerationCheck = ctx.moduleGenerationCheck; - - std::function scanInterfaceFilesRecursive = - [&](const QString& path) { - if (path.isEmpty()) - return; - if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 - - QDir dir(path); - QFileInfoList fileList = dir.entryInfoList(QStringList() << "*.*", QDir::Files); - for (const QFileInfo& fileInfo : fileList) { - QString filePath = PathUtils::normPath(fileInfo.absoluteFilePath()); - - if (shouldFilter(filePath, dirPath, completeNameList, containNameList, moduleGenerationCheck)) { - continue; - } - - bool isTransCode = PathUtils::isTransCodePath(filePath); - - FileEntry entry; - entry.path = filePath; - entry.mtime = fileInfo.lastModified().toMSecsSinceEpoch(); - entry.isTrans = isTransCode; - result.append(entry); - } - - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); - for (const QFileInfo& dirInfo : dirList) { - scanInterfaceFilesRecursive(dirInfo.absoluteFilePath()); - } - }; - - scanInterfaceFilesRecursive(dirPath); - return result; -} diff --git a/src/metadataupdate/uft3interface.h b/src/metadataupdate/uft30/uft3interface.h similarity index 58% rename from src/metadataupdate/uft3interface.h rename to src/metadataupdate/uft30/uft3interface.h index f9a37f0..830bc6f 100644 --- a/src/metadataupdate/uft3interface.h +++ b/src/metadataupdate/uft30/uft3interface.h @@ -7,7 +7,7 @@ #include #include -#include "interface_scanner.h" +#include "../common/interface_scanner.h" class Uft3Interface : public QObject, public IInterfaceScanner { @@ -16,24 +16,22 @@ public: explicit Uft3Interface(QObject *parent = nullptr); // ---- IInterfaceScanner 实现 ---- - InterfaceInfo loadInterface(const QString& filePath) override; + std::optional loadInterface(const QString& filePath) override; ScanResult scanDir(const QString& dirPath, const ScanContext& ctx) override; - InterfaceInfo assemble(const QString& filePath, const ScanContext& ctx) override; - QList collectFiles(const QString& dirPath, const ScanContext& ctx) override; private: bool shouldFilter(const QString& filePath, - const QString& dirPath, - const QStringList& completeNameList, - const QStringList& containNameList, + const QStringList& completeNames, + const QStringList& allowedExtensions, const QString& moduleGenerationCheck); void scanDirRecursive(const QString& dirPath, - const QStringList& completeNameList, - const QStringList& containNameList, + const QStringList& completeNames, + const QStringList& allowedExtensions, const QString& moduleGenerationCheck, QMap& resultMap, - QMap& transCodeMap); + QMap& transCodeMap, + int& failedCount); }; #endif // UFT3INTERFACE_H diff --git a/src/metadataupdate/uft3stdfields.cpp b/src/metadataupdate/uft30/uft3stdfields.cpp similarity index 78% rename from src/metadataupdate/uft3stdfields.cpp rename to src/metadataupdate/uft30/uft3stdfields.cpp index 1ace82d..5d5bc94 100644 --- a/src/metadataupdate/uft3stdfields.cpp +++ b/src/metadataupdate/uft30/uft3stdfields.cpp @@ -1,7 +1,6 @@ #include "uft3stdfields.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "utils/Constants.h" #include #include #include @@ -22,8 +21,14 @@ QMap> Uft3StdFields::loadData( LogManager::instance()->log(QString("加载UFT3标准字段文件: %1").arg(filePath)); QFile file(filePath); + // 与 uf2constants/uf2menuandfunction 统一日志分级: + // 文件缺失(路径配置错误)记 Warning, 打开失败(被占用/权限)记 Error + if (!file.exists()) { + LogManager::instance()->logWarning(QString("UFT3标准字段文件不存在, 返回空表: %1").arg(filePath)); + return resultMap; + } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - LogManager::instance()->logError(QString("无法打开文件: %1").arg(filePath)); + LogManager::instance()->logError(QString("UFT3标准字段文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } @@ -48,8 +53,10 @@ QMap> Uft3StdFields::loadData( QString sUftType = ""; - if (uf3datatypeMap.contains(sType)) { - sUftType = uf3datatypeMap[sType].value("uftType"); + // value() 一次查找, 替代 contains+operator[] 双重哈希 + const auto typeIt = uf3datatypeMap.constFind(sType); + if (typeIt != uf3datatypeMap.constEnd()) { + sUftType = typeIt->value("uftType"); } QMap fieldInfo; diff --git a/src/metadataupdate/uft3stdfields.h b/src/metadataupdate/uft30/uft3stdfields.h similarity index 100% rename from src/metadataupdate/uft3stdfields.h rename to src/metadataupdate/uft30/uft3stdfields.h diff --git a/src/metadataupdate/uft3table.cpp b/src/metadataupdate/uft30/uft3table.cpp similarity index 75% rename from src/metadataupdate/uft3table.cpp rename to src/metadataupdate/uft30/uft3table.cpp index 7553b0f..96a2125 100644 --- a/src/metadataupdate/uft3table.cpp +++ b/src/metadataupdate/uft30/uft3table.cpp @@ -1,7 +1,6 @@ #include "uft3table.h" #include "utils/logmanager.h" #include "utils/Constants.h" -#include "utils/Constants.h" #include #include #include @@ -15,43 +14,57 @@ Uft3Table::Uft3Table(QObject *parent) : QObject(parent) QMap> Uft3Table::loadData(const QString& dirPath) { QMap> resultMap; - + + // 同实例二次调用必须清空上次收集的文件, 否则不同目录的结果会混入 + _fileList.clear(); + QString scanPath = PathUtils::normPath(dirPath); scanPath += Constants::MetaPaths::Uft3StructDir; - + LogManager::instance()->log(QString("加载UFT3表结构目录: %1").arg(scanPath)); - + QDir dir(scanPath); if (!dir.exists()) { LogManager::instance()->logError(QString("表结构目录不存在: %1").arg(scanPath)); return resultMap; } - + scanDirectory(scanPath, resultMap); - + for (const QString& filePath : _fileList) { - if (filePath.contains(Constants::MetaPaths::Uft3StructExt)) { - parseTableFile(filePath, resultMap); + // 逐文件解析是长循环: 协作式取消, 响应后中断(结果不落库, 由调用方行级取消收尾) + if (isCancelRequested()) { + LogManager::instance()->logWarning("UFT3.0 表结构解析已取消"); + return resultMap; } + parseTableFile(filePath, resultMap); } - + LogManager::instance()->log(QString("UFT3表结构加载完成,共 %1 条").arg(resultMap.size())); - + return resultMap; } void Uft3Table::scanDirectory(const QString& dirPath, QMap>& resultMap) { + // 协作式取消: 大目录树递归收集可能持续数十秒, 中断后不再继续 + if (isCancelRequested()) { + return; + } + QDir dir(dirPath); - + // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 QFileInfoList fileList = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); - + for (const QFileInfo& fileInfo : fileList) { if (fileInfo.isDir()) { scanDirectory(fileInfo.absoluteFilePath(), resultMap); } - else if (fileInfo.isFile()) { + // 按真实后缀收集, 不用全路径 contains(".uftstructure") 判断: + // 后者会把名为 xxx.uftstructure 的目录内的 table.xml 也误收进来 + else if (fileInfo.isFile() + && fileInfo.suffix().compare(QLatin1String("uftstructure"), Qt::CaseInsensitive) == 0) { _fileList.append(fileInfo.absoluteFilePath()); } } @@ -85,12 +98,21 @@ void Uft3Table::parseTableFile(const QString& filePath, QMap= 0) { + tablePath = sFilePath.mid(rootPos + 1); } while (!xml.atEnd() && !xml.hasError()) { @@ -161,16 +183,13 @@ void Uft3Table::parseTableFile(const QString& filePath, QMaplogError(QString("表结构 XML 解析失败: %1 (%2)").arg(sFilePath, xml.errorString())); } - QString sUser = ""; - if (spaceName == "HS_UFT_DATA") { - sUser = "hs_uft"; - } - else if (spaceName == "HS_UARG_DATA") { - sUser = "hs_uarg"; - } - else if (spaceName == "HS_USMS_DATA") { - sUser = "hs_usms"; - } + // 表空间名 → 库用户名映射(无匹配为空串): 静态表集中维护, 防止 if-else 链随表空间增加而漂移 + static const QMap kSpaceUser = { + { "HS_UFT_DATA", "hs_uft" }, + { "HS_UARG_DATA", "hs_uarg" }, + { "HS_USMS_DATA", "hs_usms" }, + }; + const QString sUser = kSpaceUser.value(spaceName); QMap tableInfoDetail; tableInfoDetail["tableEngName"] = tableEngName; diff --git a/src/metadataupdate/uft30/uft3table.h b/src/metadataupdate/uft30/uft3table.h new file mode 100644 index 0000000..4af7d7d --- /dev/null +++ b/src/metadataupdate/uft30/uft3table.h @@ -0,0 +1,33 @@ +#ifndef TFT3TABLE_H +#define TFT3TABLE_H + +#include +#include +#include +#include +#include + +class Uft3Table : public QObject +{ + Q_OBJECT +public: + explicit Uft3Table(QObject *parent = nullptr); + + // 设置外部取消标志(指向 MetadataProcessor 的原子标志, 生命周期由调用方保证): + // 大工程树全表扫描可达数十万文件, 无检查点时 requestCancel 后扫描仍会跑完 + void setCancelFlag(std::atomic_bool* flag) { m_cancelFlag = flag; } + + QMap> loadData(const QString& dirPath); + +private: + // 递归扫描/解析循环中的协作式取消检查点 + bool isCancelRequested() const { return m_cancelFlag && m_cancelFlag->load(std::memory_order_relaxed); } + void scanDirectory(const QString& dirPath, QMap>& resultMap); + void parseTableFile(const QString& filePath, QMap>& resultMap); + +private: + QStringList _fileList; + std::atomic_bool* m_cancelFlag = nullptr; // 外部取消标志, 非拥有指针 +}; + +#endif diff --git a/src/metadataupdate/uft3table.h b/src/metadataupdate/uft3table.h deleted file mode 100644 index 17f36ec..0000000 --- a/src/metadataupdate/uft3table.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef TFT3TABLE_H -#define TFT3TABLE_H - -#include -#include -#include -#include - -class Uft3Table : public QObject -{ - Q_OBJECT -public: - explicit Uft3Table(QObject *parent = nullptr); - - QMap> loadData(const QString& dirPath); - -private: - void scanDirectory(const QString& dirPath, QMap>& resultMap); - void parseTableFile(const QString& filePath, QMap>& resultMap); - -private: - QStringList _fileList; -}; - -#endif diff --git a/src/pages/batchconvert/batchconvertpage.cpp b/src/pages/batchconvert/batchconvertpage.cpp index c6e5f70..4d42876 100644 --- a/src/pages/batchconvert/batchconvertpage.cpp +++ b/src/pages/batchconvert/batchconvertpage.cpp @@ -1,31 +1,18 @@ #include "batchconvertpage.h" #include "src/utils/Constants.h" -#include "src/utils/uf2configreader.h" +#include "src/utils/logmanager.h" +#include "src/utils/custjsonio.h" #include -#include -#include #include #include -#include -#include #include #include #include +#include +#include #include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include "src/utils/logmanager.h" -#include "ElaPushButton.h" -#include "ElaProgressBar.h" -#include "ElaText.h" +#include #include "ElaTheme.h" namespace { @@ -36,7 +23,7 @@ bool validateLsFunctionNames(QWidget *parent, const QStringList &functionNames) for (const QString &name : functionNames) { const QString trimmedName = name.trimmed(); if (!trimmedName.isEmpty() - && !trimmedName.startsWith("LS_") + && !trimmedName.startsWith(Constants::FuncName::LsPrefix) && !nonLsNames.contains(trimmedName)) { nonLsNames.append(trimmedName); } @@ -52,422 +39,45 @@ bool validateLsFunctionNames(QWidget *parent, const QStringList &functionNames) return false; } -} - -class StatusDelegate : public QStyledItemDelegate -{ -public: - explicit StatusDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} - - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override - { - QVariant bgColor = index.data(Qt::BackgroundRole); - QVariant fgColor = index.data(Qt::ForegroundRole); - - QStyleOptionViewItem opt = option; - initStyleOption(&opt, index); - - if (bgColor.canConvert()) { - painter->fillRect(opt.rect, bgColor.value()); - } - - if (fgColor.canConvert()) { - painter->setPen(fgColor.value().color()); - } - - painter->drawText(opt.rect, opt.displayAlignment, opt.text); - } -}; +} // namespace BatchConvertPage::BatchConvertPage(QWidget *parent) - : QWidget(parent) + : ConvertWorkbenchBase(parent) { - initUI(); - loadFromCustJson(); - - // 防抖定时器:编辑器保存可能触发多次信号,合并为一次重载 - m_reloadTimer = new QTimer(this); - m_reloadTimer->setSingleShot(true); - m_reloadTimer->setInterval(300); - connect(m_reloadTimer, &QTimer::timeout, this, &BatchConvertPage::onReloadTimeout); - - // 记录初始 mtime - m_lastFileMtime = currentFileMtime(); - - setupConfigWatcher(); - - // 灰字进度标签跟随主题明暗切换(上轮修改实际未落盘, 此处补全) - connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, - this, &BatchConvertPage::updateTipStyle); - updateTipStyle(); + // 两阶段初始化: 构造体内调用, 确保界面钩子(loadConfig/applyPageLayout 等)派发到本类 + initialize(); } -BatchConvertPage::~BatchConvertPage() +void BatchConvertPage::applyPageLayout(QVBoxLayout *layout) { - if (m_configWatcher) { - m_configWatcher->removePaths(m_configWatcher->files()); - m_configWatcher->removePaths(m_configWatcher->directories()); - } -} - -void BatchConvertPage::initUI() -{ - QVBoxLayout *layout = new QVBoxLayout(this); layout->setContentsMargins(30, 30, 30, 30); layout->setSpacing(20); - - QGroupBox *funcBox = new QGroupBox("转码功能"); - QVBoxLayout *funcLayout = new QVBoxLayout(funcBox); - funcLayout->setSpacing(10); - - m_tableModel = new QStandardItemModel(this); - m_tableModel->setColumnCount(3); - m_tableModel->setHorizontalHeaderLabels({"UF20函数名", "是否存在", "操作"}); - - m_funcTable = new ElaTableView; - m_funcTable->setModel(m_tableModel); - m_funcTable->setMinimumHeight(200); - m_funcTable->setSelectionBehavior(QAbstractItemView::SelectRows); - m_funcTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setStretchLastSection(false); - m_funcTable->setColumnWidth(2, 80); - m_funcTable->setItemDelegateForColumn(1, new StatusDelegate(this)); - funcLayout->addWidget(m_funcTable); - - QHBoxLayout *tableBtnLayout = new QHBoxLayout; - ElaPushButton *addBtn = new ElaPushButton("添加"); - connect(addBtn, &QPushButton::clicked, this, &BatchConvertPage::onAddFunction); - ElaPushButton *removeBtn = new ElaPushButton("删除选中"); - connect(removeBtn, &QPushButton::clicked, this, &BatchConvertPage::onRemoveFunction); - ElaPushButton *clearBtn = new ElaPushButton("清空列表"); - connect(clearBtn, &QPushButton::clicked, this, &BatchConvertPage::onClearTable); - tableBtnLayout->addWidget(addBtn); - tableBtnLayout->addStretch(); - tableBtnLayout->addWidget(removeBtn); - tableBtnLayout->addWidget(clearBtn); - funcLayout->addLayout(tableBtnLayout); - - layout->addWidget(funcBox); - - QGroupBox *progressBox = new QGroupBox("转换进度"); - progressBox->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - QVBoxLayout *progressLayout = new QVBoxLayout(progressBox); - progressLayout->setSpacing(10); - - m_progressLabel = new ElaText("等待开始..."); - m_progressLabel->setFixedHeight(40); - m_progressLabel->setWordWrap(false); - m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - progressLayout->addWidget(m_progressLabel); - - m_progressBar = new ElaProgressBar; - m_progressBar->setRange(0, 100); - m_progressBar->setValue(0); - m_progressBar->setFixedHeight(25); - m_progressBar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - progressLayout->addWidget(m_progressBar); - - layout->addWidget(progressBox); - - QHBoxLayout *btnLayout = new QHBoxLayout; - m_startBtn = new QPushButton("开始转换"); - m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); - connect(m_startBtn, &QPushButton::clicked, this, &BatchConvertPage::onStartConvert); - btnLayout->addStretch(); - btnLayout->addWidget(m_startBtn); - layout->addLayout(btnLayout); } -void BatchConvertPage::onProgressUpdate(int progress, const QString& status) +int BatchConvertPage::tableColumnCount() const { - m_progressBar->setValue(progress); - - // 仅当传入有效状态文字时才更新标签,避免无意义的日志行覆盖当前处理状态 - if (!status.isEmpty()) { - QString displayStatus = status; - if (displayStatus.length() > 80) { - displayStatus = displayStatus.left(80) + "..."; - } - m_progressLabel->setText(displayStatus); - } + return 3; } -bool BatchConvertPage::checkFunctionExists(const QString &funcName) +QStringList BatchConvertPage::tableHeaderLabels() const { - return UF2ConfigReader::checkFuncExistsStatic(funcName); + return {"UF20函数名", "是否存在", "操作"}; } -void BatchConvertPage::updateTable() +void BatchConvertPage::applyColumnLayout() { - m_tableModel->removeRows(0, m_tableModel->rowCount()); - - for (int i = 0; i < m_funcList.size(); ++i) { - QString funcName = m_funcList[i]; - - QStandardItem *nameItem = new QStandardItem(funcName); - nameItem->setFlags(nameItem->flags() & ~Qt::ItemIsEditable); - - bool exists = checkFunctionExists(funcName); - QString statusText = exists ? "是" : "否"; - - QStandardItem *statusItem = new QStandardItem(statusText); - statusItem->setFlags(statusItem->flags() & ~Qt::ItemIsEditable); - statusItem->setTextAlignment(Qt::AlignCenter); - - if (exists) { - statusItem->setData(QBrush(QColor(144, 238, 144)), Qt::BackgroundRole); - statusItem->setData(QBrush(QColor(0, 100, 0)), Qt::ForegroundRole); - } else { - statusItem->setData(QBrush(QColor(255, 182, 193)), Qt::BackgroundRole); - statusItem->setData(QBrush(QColor(139, 0, 0)), Qt::ForegroundRole); - } - - QStandardItem *actionItem = new QStandardItem(""); - actionItem->setFlags(actionItem->flags() & ~Qt::ItemIsEditable); - - QList rowItems; - rowItems.append(nameItem); - rowItems.append(statusItem); - rowItems.append(actionItem); - m_tableModel->appendRow(rowItems); - - QPushButton *editBtn = new QPushButton; - editBtn->setIcon(QIcon(":/resources/images/bianji.png")); - editBtn->setIconSize(QSize(20, 20)); - editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;"); - editBtn->setToolTip("修改"); - // QueuedConnection: onEditRow 经 loadFromCustJson 重建表格会析构 editBtn 自身, - // 同步回调返回到按钮内部事件处理时构成 use-after-free, 故延迟到事件循环执行。 - // 捕获函数名而非行号且以按钮为接收者: 排队前表格可能被重建, 行号会指向错误行; - // 函数名是稳定键, 且按钮析构时过期的排队请求随之失效 - connect(editBtn, &QPushButton::clicked, editBtn, [this, funcName]() { - onEditRowByName(funcName); - }, Qt::QueuedConnection); - m_funcTable->setIndexWidget(m_tableModel->index(i, 2), editBtn); - } - - int totalWidth = m_funcTable->viewport()->width(); + const int totalWidth = m_funcTable->viewport()->width(); m_funcTable->setColumnWidth(0, (totalWidth - 80) * 8 / 10); m_funcTable->setColumnWidth(1, (totalWidth - 80) * 2 / 10); m_funcTable->setColumnWidth(2, 80); } -void BatchConvertPage::resizeEvent(QResizeEvent *event) +bool BatchConvertPage::validateFunctionNames(const QStringList &names) { - QWidget::resizeEvent(event); - if (m_funcTable) { - int totalWidth = m_funcTable->width(); - m_funcTable->setColumnWidth(0, (totalWidth - 80) * 8 / 10); - m_funcTable->setColumnWidth(1, (totalWidth - 80) * 2 / 10); - m_funcTable->setColumnWidth(2, 80); - } + return validateLsFunctionNames(this, names); } -void BatchConvertPage::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - // 每次显示页面时刷新"是否存在"列 - // (元数据更新后 DataCache 变化,需要重新检查函数存在状态) - updateTable(); -} - -// 按函数名定位行再编辑: 表格可能在外部文件监控/切页时被全量重建, -// 排队请求中的旧行号会失效指向错误行, 函数名是唯一稳定键 -void BatchConvertPage::onEditRowByName(const QString &funcName) -{ - const int row = m_funcList.indexOf(funcName); - if (row >= 0) { - onEditRow(row); - } -} - -void BatchConvertPage::onEditRow(int row) -{ - if (row < 0 || row >= m_funcList.size()) { - return; - } - - QString oldName = m_funcList[row]; - QString newName = oldName; - - while (true) { - QInputDialog dialog(this); - dialog.setWindowTitle("修改函数名"); - dialog.setLabelText("请输入新的函数名:"); - dialog.setTextValue(oldName); - dialog.resize(300, 150); - - if (dialog.exec() != QDialog::Accepted) { - return; - } - - newName = dialog.textValue().trimmed(); - - if (newName.isEmpty()) { - QMessageBox::warning(this, "提示", "函数名不能为空!"); - continue; - } - - if (!validateLsFunctionNames(this, QStringList{newName})) { - continue; - } - - if (newName == oldName) { - return; - } - - if (m_funcList.contains(newName)) { - QMessageBox::warning(this, "提示", "该函数名已存在,请重新输入!"); - continue; - } - - break; - } - - m_funcList[row] = newName; - updateTable(); - if (saveToCustJson(m_funcList)) { - loadFromCustJson(); - } else { - QMessageBox::warning(this, "保存失败", "函数名已在内存中修改,但写入 cust.json 失败(文件被占用或权限不足?),重启后修改将丢失!"); - } -} - -void BatchConvertPage::onAddFunction() -{ - QDialog dialog(this); - dialog.setWindowTitle("添加UF20函数"); - dialog.resize(400, 250); - - QVBoxLayout *layout = new QVBoxLayout(&dialog); - - QTextEdit *textEdit = new QTextEdit; - textEdit->setPlaceholderText("每行输入一个函数名"); - layout->addWidget(textEdit); - - QHBoxLayout *btnLayout = new QHBoxLayout; - QPushButton *okBtn = new QPushButton("确定"); - QPushButton *cancelBtn = new QPushButton("取消"); - btnLayout->addStretch(); - btnLayout->addWidget(okBtn); - btnLayout->addWidget(cancelBtn); - layout->addLayout(btnLayout); - - connect(okBtn, &QPushButton::clicked, &dialog, [this, &dialog, textEdit]() { - const QStringList funcNames = - textEdit->toPlainText().split('\n', Qt::SkipEmptyParts); - if (validateLsFunctionNames(this, funcNames)) { - dialog.accept(); - } - }); - connect(cancelBtn, &QPushButton::clicked, &dialog, &QDialog::reject); - - if (dialog.exec() != QDialog::Accepted) { - return; - } - - QString text = textEdit->toPlainText().trimmed(); - if (text.isEmpty()) { - return; - } - - QStringList funcNames = text.split('\n', Qt::SkipEmptyParts); - int addedCount = 0; - int existCount = 0; - - for (const QString &name : funcNames) { - QString trimmedName = name.trimmed(); - if (trimmedName.isEmpty()) { - continue; - } - if (m_funcList.contains(trimmedName)) { - existCount++; - continue; - } - m_funcList.append(trimmedName); - addedCount++; - } - - if (existCount > 0) { - QMessageBox::information(this, "提示", - QString("成功添加 %1 个函数,%2 个函数已存在").arg(addedCount).arg(existCount)); - } else if (addedCount > 0) { - QMessageBox::information(this, "提示", - QString("成功添加 %1 个函数").arg(addedCount)); - } - - // 落盘: 新增函数持久化到 cust.json, 否则只写内存重启后丢失(与 onEditRow 保持一致) - if (addedCount > 0) { - if (saveToCustJson(m_funcList)) { - loadFromCustJson(); - } else { - QMessageBox::warning(this, "保存失败", "新增函数已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将丢失!"); - } - } - updateTable(); -} - -void BatchConvertPage::onRemoveFunction() -{ - QModelIndexList selectedIndexes = m_funcTable->selectionModel()->selectedRows(); - if (selectedIndexes.isEmpty()) { - QMessageBox::warning(this, "提示", "请先选中要删除的行!"); - return; - } - - QList rowsToRemove; - for (const QModelIndex &index : selectedIndexes) { - int row = index.row(); - if (!rowsToRemove.contains(row)) { - rowsToRemove.append(row); - } - } - - std::sort(rowsToRemove.begin(), rowsToRemove.end(), std::greater()); - for (int row : rowsToRemove) { - m_funcList.removeAt(row); - } - - updateTable(); - if (saveToCustJson(m_funcList)) { - loadFromCustJson(); - } else { - QMessageBox::warning(this, "保存失败", "删除操作已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将恢复原清单!"); - } -} - -void BatchConvertPage::onClearTable() -{ - if (m_funcList.isEmpty()) { - return; - } - - QMessageBox::StandardButton reply; - reply = QMessageBox::question(this, "确认清空", "确定要清空所有函数吗?", - QMessageBox::Yes | QMessageBox::No); - if (reply == QMessageBox::Yes) { - m_funcList.clear(); - updateTable(); - if (saveToCustJson(m_funcList)) { - loadFromCustJson(); - } else { - QMessageBox::warning(this, "保存失败", "清空操作已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将恢复原清单!"); - } - } -} - -void BatchConvertPage::updateTipStyle() -{ - if (!m_progressLabel) return; - const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); - const QString textColor = ElaThemeColor(mode, BasicText).name(); - // 仅联动颜色: ElaText 保持默认字号(与"按需转码"页一致), 强设 12px 过小(用户反馈) - m_progressLabel->setStyleSheet(QString("color: %1;").arg(textColor)); -} - -bool BatchConvertPage::saveToCustJson(const QStringList &funcList) +bool BatchConvertPage::saveConfig(const QStringList &funcList) { QString jsonPath = Constants::custJson(); @@ -482,8 +92,9 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList) QString content = QString::fromUtf8(file.readAll()); file.close(); - // 用正则定位 "convert" 节点(兼容任意空格) - static QRegularExpression convertRe(R"("convert"\s*:)"); + // 用正则定位 convert 顶层键(兼容任意空格) + static const QRegularExpression convertRe( + QString(R"("%1"\s*:)").arg(Constants::CustKeys::ConvertDir)); auto convertMatch = convertRe.match(content); if (!convertMatch.hasMatch()) { LogManager::instance()->logWarning("全量转码 - 保存配置失败,未找到 convert 节点"); @@ -525,17 +136,10 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList) int bracketEnd = pos; // 指向 ']' 之后一位 // 构建新数组(保持原始缩进风格:项 12 空格,闭合 ] 8 空格) - // 函数名做 JSON 字符串转义, 防止含引号/反斜杠时产生非法 JSON - auto jsonEscape = [](const QString &s) -> QString { - QString out = s; - out.replace('\\', "\\\\"); - out.replace('"', "\\\""); - return out; - }; QString newArray = "["; for (int i = 0; i < funcList.size(); ++i) { if (i > 0) newArray += ","; - newArray += "\n \"" + jsonEscape(funcList[i]) + ".service_design\""; + newArray += "\n \"" + CustJsonIo::escapeJsonString(funcList[i]) + Constants::DesignExts::Service + "\""; } if (!funcList.isEmpty()) { newArray += "\n "; @@ -555,7 +159,11 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList) } // QSaveFile 原子写: 先写临时文件, commit 成功才替换目标文件, - // 避免写入中途崩溃/断电损坏配置 + // 避免写入中途崩溃/断电损坏配置。 + // 保存窗口: 写入前开启, 窗口期内 QSaveFile 替换目标文件引发的 + // fileChanged/directoryChanged 监控事件直接忽略, 防止自身保存触发重载; + // 失败路径(打开/提交失败)由组件 2s 兜底自动关窗 + m_configMonitor->beginSaveWindow(); QSaveFile saveFile(jsonPath); if (!saveFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { LogManager::instance()->logWarning( @@ -570,13 +178,13 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList) return false; } - // 程序自身写入后更新 mtime,避免触发无谓的重载 - m_lastFileMtime = currentFileMtime(); + // 程序自身写入后更新监控 mtime 基准, 避免触发无谓的重载 + m_configMonitor->endSave(); return true; } -void BatchConvertPage::loadFromCustJson() +void BatchConvertPage::loadConfig() { QString jsonPath = Constants::custJson(); @@ -599,210 +207,42 @@ void BatchConvertPage::loadFromCustJson() } QJsonObject root = doc.object(); - QJsonArray dir = root["convert"].toObject()["dir"].toArray(); + QJsonArray dir = root[Constants::CustKeys::ConvertDir].toObject()[Constants::CustKeys::ConvertDirSub].toArray(); QStringList items; for (const QJsonValue &val : dir) { QString item = val.toString(); - if (item.endsWith(".service_design")) { - item.chop(static_cast(strlen(".service_design"))); + if (item.endsWith(Constants::DesignExts::Service)) { + item.chop(Constants::DesignExts::Service.size()); } items.append(item); } m_funcList = items; - updateTable(); + refreshTable(); } -void BatchConvertPage::onStartConvert() -{ - if (m_startBtn->text() == "停止转换") { - m_progressLabel->setText("正在停止转换..."); - emit stopConvert(); - return; - } - - if (m_funcList.isEmpty()) { - QMessageBox::warning(this, "提示", "请先添加需要转码的函数!"); - return; - } - - m_progressBar->setValue(0); - m_progressLabel->setText("正在准备转换..."); - - if (saveToCustJson(m_funcList)) { - m_progressLabel->setText("配置加载成功,开始转换..."); - m_progressBar->setValue(20); - } else { - m_progressLabel->setText("配置加载失败"); - return; - } - - emit startConvert(); -} - -void BatchConvertPage::onConvertStarted() -{ - m_startBtn->setText("停止转换"); - m_startBtn->setStyleSheet("background-color: #e74c3c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); -} - -void BatchConvertPage::onConvertFinished() -{ - m_progressBar->setValue(100); - m_progressLabel->setText("转换完成"); - m_startBtn->setText("开始转换"); - m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); -} - -void BatchConvertPage::onConvertFailed(const QString &detail) -{ - // 失败收尾: 进度条保持当前进度(不复位 100%, 与成功完成区分), 文字提示转换失败 - m_progressLabel->setText(detail.isEmpty() ? "转换失败" : detail); - m_startBtn->setText("开始转换"); - m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); -} - -void BatchConvertPage::setTaskBusy(bool busy) -{ - if (!m_startBtn) { - return; - } - m_startBtn->setEnabled(!busy); - if (busy) { - m_progressBar->setValue(0); - m_progressLabel->setText("有其他转换任务进行中,请等待完成..."); - } else { - m_progressLabel->setText("等待开始..."); - } -} - -void BatchConvertPage::addFunction(const QString &funcName) -{ - if (funcName.isEmpty()) { - return; - } - - if (m_funcList.contains(funcName)) { - QMessageBox::information(nullptr, "提示", QString("功能 [%1] 已在列表中").arg(funcName)); - return; - } - - m_funcList.append(funcName); - updateTable(); - - if (saveToCustJson(m_funcList)) { - QMessageBox::information(nullptr, "提示", QString("已将功能 [%1] 添加到\"全量转码\"").arg(funcName)); - } else { - QMessageBox::warning(nullptr, "警告", "添加到\"全量转码\"成功,但保存到 cust.json 失败"); - } -} - -QPair BatchConvertPage::addFunctionsBatch(const QStringList &funcNames, bool notify) -{ - // 批量添加(功能查询页多选双击): 不逐条弹窗; - // notify=true 时结束后一次汇总提示, notify=false 静默(由调用方统一汇总), 返回 {新增数, 跳过数} - QStringList added; - int skipped = 0; - for (const QString &funcName : funcNames) { - if (funcName.isEmpty()) { - continue; - } - if (m_funcList.contains(funcName)) { - ++skipped; - continue; - } - m_funcList.append(funcName); - added.append(funcName); - } - - if (added.isEmpty()) { - if (notify && skipped > 0) { - QMessageBox::information(nullptr, "提示", - QString("所选 %1 个功能均已在\"全量转码\"列表中").arg(skipped)); - } - return qMakePair(0, skipped); - } - - updateTable(); - if (!saveToCustJson(m_funcList)) { - QMessageBox::warning(nullptr, "警告", - QString("添加成功,但保存到 cust.json 失败(%1 个功能)").arg(added.size())); - return qMakePair(-1, -1); - } - if (notify) { - QMessageBox::information(nullptr, "提示", - QString("已将 %1 个功能加入\"全量转码\"").arg(added.size()) + - (skipped > 0 ? QString("(另有 %1 个已存在,已跳过)").arg(skipped) : QString())); - } - return qMakePair(added.size(), skipped); -} - -void BatchConvertPage::setupConfigWatcher() -{ - QString configPath = getConfigFilePath(); - if (configPath.isEmpty()) { - return; - } - - m_configWatcher = new QFileSystemWatcher(this); - - // 1. 监视文件本身(覆盖直接编辑场景) - if (QFile::exists(configPath)) { - m_configWatcher->addPath(configPath); - } - - // 2. 监视文件所在目录(覆盖编辑器原子替换:写临时文件→删除→重命名) - // Windows 上 fileChanged 在文件被替换后常失效,directoryChanged 更可靠 - QString dir = QFileInfo(configPath).absolutePath(); - if (QDir(dir).exists()) { - m_configWatcher->addPath(dir); - } - - connect(m_configWatcher, &QFileSystemWatcher::fileChanged, - this, &BatchConvertPage::onConfigFileChanged); - connect(m_configWatcher, &QFileSystemWatcher::directoryChanged, - this, &BatchConvertPage::onConfigDirChanged); -} - -QDateTime BatchConvertPage::currentFileMtime() const -{ - QFileInfo info(getConfigFilePath()); - return info.exists() ? info.lastModified() : QDateTime(); -} - -void BatchConvertPage::onConfigFileChanged(const QString &path) -{ - // 文件可能被编辑器替换导致监视失效,尝试重新添加 - if (m_configWatcher && !m_configWatcher->files().contains(path) - && QFile::exists(path)) { - m_configWatcher->addPath(path); - } - m_reloadTimer->start(); // 防抖 -} - -void BatchConvertPage::onConfigDirChanged(const QString & /*dirPath*/) -{ - // 目录变化时重新监视文件(可能之前文件不存在/被替换) - QString configPath = getConfigFilePath(); - if (m_configWatcher && QFile::exists(configPath) - && !m_configWatcher->files().contains(configPath)) { - m_configWatcher->addPath(configPath); - } - m_reloadTimer->start(); // 防抖 -} - -void BatchConvertPage::onReloadTimeout() -{ - // 比较 mtime,避免无谓重载 - QDateTime now = currentFileMtime(); - if (now.isValid() && now != m_lastFileMtime) { - m_lastFileMtime = now; - loadFromCustJson(); - } -} - -QString BatchConvertPage::getConfigFilePath() const +QString BatchConvertPage::configFilePath() const { return Constants::custJson(); } + +QString BatchConvertPage::workbenchTitle() const +{ + return "全量转码"; +} + +QString BatchConvertPage::configDisplayName() const +{ + return "cust.json"; +} + +QString BatchConvertPage::addDialogTitle() const +{ + return "添加UF20函数"; +} + +QString BatchConvertPage::addDialogPlaceholder() const +{ + return "每行输入一个函数名"; +} diff --git a/src/pages/batchconvert/batchconvertpage.h b/src/pages/batchconvert/batchconvertpage.h index 28e181b..fcf6952 100644 --- a/src/pages/batchconvert/batchconvertpage.h +++ b/src/pages/batchconvert/batchconvertpage.h @@ -1,79 +1,39 @@ #ifndef BATCHCONVERTPAGE_H #define BATCHCONVERTPAGE_H -#include -#include -#include -#include -#include -#include -#include -#include "ElaLineEdit.h" -#include "ElaTableView.h" -#include "ElaProgressBar.h" -#include "ElaText.h" +#include "src/pages/common/convertworkbenchbase.h" -class BatchConvertPage : public QWidget +class QVBoxLayout; + +// 全量转码工作台(业务转码页 LS pivot,仅支持 LS_ 开头的功能) +// 函数清单(短名)持久化到 config/cust.json,convert.dir 为 +// ["功能名.service_design", ...] 数组,保存时对原文件做文本级区间替换 +// (保留 cust.json 其余全部格式不重序列化) +class BatchConvertPage : public ConvertWorkbenchBase { Q_OBJECT public: explicit BatchConvertPage(QWidget *parent = nullptr); - ~BatchConvertPage(); -signals: - void startConvert(); - void stopConvert(); +protected: + // 界面骨架钩子 + void applyPageLayout(QVBoxLayout *layout) override; + int tableColumnCount() const override; + QStringList tableHeaderLabels() const override; + void applyColumnLayout() override; -public slots: - void onProgressUpdate(int progress, const QString& status); - void onConvertFinished(); - // 失败收尾: 由 MainWindow 在引擎异常退出或检测到 FAIL 行时调用(见 onPythonRunnerFinished) - void onConvertFailed(const QString &detail); - void onConvertStarted(); - void addFunction(const QString &funcName); - // 批量添加(功能查询页多选双击): 不逐条弹窗, 结束后一次汇总提示; - // notify=false 为静默模式(不弹提示框), 返回 {新增数, 跳过数}, 保存失败返回 {-1, -1}(已弹警告) - QPair addFunctionsBatch(const QStringList &funcNames, bool notify = true); - // 另一工作台任务运行时由 MainWindow 调用,禁用本页开始按钮 - void setTaskBusy(bool busy); + // 名称校验钩子: 非 LS_ 前缀弹窗拦截 + bool validateFunctionNames(const QStringList &names) override; - int getFunctionCount() const { return m_funcList.size(); } - QStringList getFunctionList() const { return m_funcList; } - -private slots: - void onStartConvert(); - void onAddFunction(); - void onRemoveFunction(); - void onClearTable(); - void onEditRow(int row); - void onEditRowByName(const QString &funcName); - void onConfigFileChanged(const QString &path); - void onConfigDirChanged(const QString &path); - void onReloadTimeout(); - void updateTipStyle(); // 灰字进度标签跟随主题明暗切换 - -private: - void initUI(); - bool saveToCustJson(const QStringList &funcList); - void loadFromCustJson(); - bool checkFunctionExists(const QString &funcName); - void updateTable(); - void resizeEvent(QResizeEvent *event) override; - void showEvent(QShowEvent *event) override; - QString getConfigFilePath() const; - void setupConfigWatcher(); - QDateTime currentFileMtime() const; - - ElaTableView *m_funcTable; - QStandardItemModel *m_tableModel; - QStringList m_funcList; - ElaProgressBar *m_progressBar; - ElaText *m_progressLabel; - QPushButton *m_startBtn; - QFileSystemWatcher *m_configWatcher; - QTimer *m_reloadTimer; - QDateTime m_lastFileMtime; + // 持久化钩子 + bool saveConfig(const QStringList &funcList) override; + void loadConfig() override; + QString configFilePath() const override; + QString workbenchTitle() const override; + QString configDisplayName() const override; + QString addDialogTitle() const override; + QString addDialogPlaceholder() const override; }; #endif diff --git a/src/pages/codesearch/codesearchpage.cpp b/src/pages/codesearch/codesearchpage.cpp index 6f89da4..4fcb450 100644 --- a/src/pages/codesearch/codesearchpage.cpp +++ b/src/pages/codesearch/codesearchpage.cpp @@ -1,7 +1,6 @@ #include "codesearchpage.h" #include -#include #include #include #include @@ -37,17 +36,25 @@ namespace { QFile file(custPath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + // 打开失败不静默: 转码工作台存在列来自此文件, 静默返回会让"文件不存在"与 + // "文件内容异常"难以区分, 排障时无法定位 + LogManager::instance()->logWarning(QString("转码函数清单读取 - 打开 %1 失败(未做过元数据更新?)").arg(custPath)); return result; } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + QJsonParseError parseErr; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseErr); + if (doc.isNull()) { + LogManager::instance()->logWarning(QString("转码函数清单读取 - %1 JSON 解析失败(%2)").arg(custPath, parseErr.errorString())); + return result; + } if (!doc.isObject()) { return result; } - QJsonObject convertObj = doc.object().value("convert").toObject(); - QJsonArray dirArray = convertObj.value("dir").toArray(); - QJsonArray extArray = convertObj.value("ext").toArray(); + QJsonObject convertObj = doc.object().value(Constants::CustKeys::ConvertDir).toObject(); + QJsonArray dirArray = convertObj.value(Constants::CustKeys::ConvertDirSub).toArray(); + QJsonArray extArray = convertObj.value(Constants::CustKeys::ConvertExtSub).toArray(); QStringList extList; for (const QJsonValue &v : extArray) { @@ -76,12 +83,40 @@ namespace { // 当前查询的函数/功能行高亮角色:强制绘制蓝色背景,突出查询节点 const int RowHighlightBlueRole = Qt::UserRole + 101; + // 行高亮配色: 树节点着色(setBackground)与 delegate 强制重绘必须使用同一组颜色, + // 否则主题覆盖背景后 delegate 按角色重绘的颜色与期望不一致 + const QColor kHighlightBlueBg(189, 215, 238); + const QColor kHighlightBlueFg(10, 60, 120); + const QColor kHighlightGreenBg(198, 239, 206); + const QColor kHighlightGreenFg(0, 97, 0); + + // 查询方式下拉框 item data 标识: 判断走 currentData 而非展示文案比对, + // 避免"功能号"等文案调整后查询逻辑静默失效 + const QString kQueryModeName = QStringLiteral("name"); + const QString kQueryModeNo = QStringLiteral("no"); + // runQuery 后台任务的结果: 调用链构建在 worker 线程执行, 完成后回主线程渲染 struct QueryResult { CallChainAnalyzer::TreeNode downTree; QList roots; }; + // 调用链路文本统一原子写(QSaveFile): 打开/提交失败返回 false, + // 避免写一半崩溃时半截 _call_chain.txt 替换掉目标文件 + bool writeChainTextFile(const QString &txtPath, const QStringList &chainLines) + { + QSaveFile txtFile(txtPath); + if (!txtFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + return false; + } + QTextStream ts(&txtFile); + for (const QString &line : chainLines) { + ts << line << "\n"; + } + ts.flush(); + return txtFile.commit(); + } + class CallTreeDelegate : public QStyledItemDelegate { public: @@ -95,8 +130,8 @@ namespace { const bool green = index.data(RowHighlightRole).toBool(); if (blue || green) { // 高亮行:直接绘制背景 + 深字(支持节点为叶子,无展开箭头,可整格重绘) - const QColor bg = blue ? QColor(189, 215, 238) : QColor(198, 239, 206); - const QColor fg = blue ? QColor(10, 60, 120) : QColor(0, 97, 0); + const QColor& bg = blue ? kHighlightBlueBg : kHighlightGreenBg; + const QColor& fg = blue ? kHighlightBlueFg : kHighlightGreenFg; painter->save(); painter->fillRect(option.rect, bg); painter->setPen(fg); @@ -128,21 +163,24 @@ CodeSearchPage::CodeSearchPage(QWidget *parent) QSettings cc(iniPath, QSettings::IniFormat); QString convertPath = cc.value(Constants::CodeSearch::ConvertPathKey).toString(); QString searchPath = cc.value(Constants::CodeSearch::SearchPathKey).toString(); - if (convertPath.isEmpty()) { + // 仅在键原本缺失/为空时写回默认路径: 避免每次启动都重写 ini(磁盘写+sync 同步阻塞)。 + // 先记录原始状态再兜底, 否则下方 isEmpty 判断恒为 false, 写回分支永不执行(首次启动不落 ini) + const bool convertMissing = convertPath.isEmpty(); + const bool searchMissing = searchPath.isEmpty(); + if (convertMissing) { convertPath = Constants::changeCodeDir(); } - if (searchPath.isEmpty()) { + if (searchMissing) { searchPath = Constants::changeCodeDir(); } m_pathByMode[ConvertMode] = convertPath; m_pathByMode[SearchMode] = searchPath; - // 仅在键原本缺失/为空时写回默认路径: 避免每次启动都重写 ini(磁盘写+sync 同步阻塞) - if (convertPath.isEmpty() || searchPath.isEmpty()) { - if (convertPath.isEmpty()) { + if (convertMissing || searchMissing) { + if (convertMissing) { cc.setValue(Constants::CodeSearch::ConvertPathKey, QDir::fromNativeSeparators(m_pathByMode[ConvertMode])); } - if (searchPath.isEmpty()) { + if (searchMissing) { cc.setValue(Constants::CodeSearch::SearchPathKey, QDir::fromNativeSeparators(m_pathByMode[SearchMode])); } @@ -163,9 +201,11 @@ CodeSearchPage::~CodeSearchPage() for (auto *w : m_activeWatchers) { w->waitForFinished(); } - // 若析构时有在途后台任务, 其 finished 回调不会再执行, 等待光标须在此恢复; - // 守卫检查: 栈空时 restoreOverrideCursor 会 qWarning - if (QApplication::overrideCursor()) { + // 若析构时有在途后台任务, 其 finished 回调不会再执行, 等待光标须在此恢复。 + // 守卫用 m_activeWatchers 而非 overrideCursor(): 后者检查的是全局栈非空, + // 若本页光标已被 finished 回调正常恢复、栈顶是其他组件(如模态进度框)设置的 + // 覆盖光标, 无条件弹出会误恢复别人的等待光标 + if (!m_activeWatchers.isEmpty() && QApplication::overrideCursor()) { QApplication::restoreOverrideCursor(); } } @@ -197,7 +237,8 @@ void CodeSearchPage::initUI() pathLabel->setTextPixelSize(14); pathLayout->addWidget(pathLabel); - m_pathEdit = new ElaLineEdit(); + // 传入this避免ElaLineEdit构造函数setVisible(true)在无父时弹出短暂顶层窗口 + m_pathEdit = new ElaLineEdit(this); m_pathEdit->setText(m_pathByMode[ConvertMode]); m_pathEdit->setPlaceholderText("请选择需要查询的目录"); pathLayout->addWidget(m_pathEdit, 1); @@ -230,12 +271,13 @@ void CodeSearchPage::initUI() queryLayout->setSpacing(8); m_queryCombo = new ElaComboBox(); - m_queryCombo->addItem("函数名"); - m_queryCombo->addItem("功能号"); + // 查询方式以 item data 标识, 判断处用 currentData 比对(见 kQueryMode* 常量注释) + m_queryCombo->addItem("函数名", kQueryModeName); + m_queryCombo->addItem("功能号", kQueryModeNo); m_queryCombo->setCurrentText("函数名"); queryLayout->addWidget(m_queryCombo); - m_queryEdit = new ElaLineEdit(); + m_queryEdit = new ElaLineEdit(this); m_queryEdit->setPlaceholderText("输入函数名或功能号,回车查询"); queryLayout->addWidget(m_queryEdit, 1); @@ -391,9 +433,15 @@ void CodeSearchPage::showEvent(QShowEvent *event) { QWidget::showEvent(event); - // 每次显示页面都重新读取 cust.json 的 convert 列表, - // 以同步「业务转码」中新增/删除的转码函数 - loadFunctionList(); + // 仅当 cust.json 发生变化时才重读 convert 列表(mtime 脏标记), + // 以同步「业务转码」中新增/删除的转码函数;文件缺失按 -1 处理(重读得空表) + QFileInfo custInfo(Constants::custJson()); + const qint64 custMtime = custInfo.exists() + ? custInfo.lastModified().toMSecsSinceEpoch() : -1; + if (custMtime != m_lastCustMtime) { + m_lastCustMtime = custMtime; + loadFunctionList(); + } // 默认自动构建索引(仅首次/路径未变更且未索引时); // 若用户手动改过路径,则需其点"刷新索引"手动触发 @@ -402,6 +450,8 @@ void CodeSearchPage::showEvent(QShowEvent *event) void CodeSearchPage::loadFunctionList() { + // 批量重建期间抑制逐行重绘(与调用链树填充同模式) + m_funcTable->setUpdatesEnabled(false); m_funcModel->removeRows(0, m_funcModel->rowCount()); const QStringList functions = loadConvertFunctions(); @@ -411,6 +461,7 @@ void CodeSearchPage::loadFunctionList() item->setData(func, Qt::UserRole + 1); m_funcModel->appendRow(item); } + m_funcTable->setUpdatesEnabled(true); // 业务转码函数列表加载完成(结果直接体现在左侧表格,无需顶部状态栏提示) } @@ -564,9 +615,16 @@ void CodeSearchPage::populateTree(const QList &root model->removeRows(0, model->rowCount()); QStandardItem *rootItem = model->invisibleRootItem(); + // 批量重建期间抑制逐节点重绘: 调用链树可达数千节点, 每个 appendRow 都会触发 + // 视图增量 layout/repaint, 关闭更新后仅在填充完成时整体呈现一次 + tree->setUpdatesEnabled(false); + // 同一棵树内函数名重复出现(被多个节点调用)时备忘录化, 避免每个 supported 节点 + // 都做 1~2 轮 DataCache 全量线性扫描(数百节点 × 数千函数的主线程开销) + QHash funcNoCache; for (const CallChainAnalyzer::TreeNode &r : rootNodes) { - addTreeItem(rootItem, r); + addTreeItem(rootItem, r, funcNoCache); } + tree->setUpdatesEnabled(true); tree->expandToDepth(0); @@ -628,19 +686,20 @@ void CodeSearchPage::autoResizeColumns(ElaTreeView *tree) } } -void CodeSearchPage::addTreeItem(QStandardItem *parentItem, const CallChainAnalyzer::TreeNode &node) +void CodeSearchPage::addTreeItem(QStandardItem *parentItem, const CallChainAnalyzer::TreeNode &node, + QHash &funcNoCache) { QString nameText = node.displayName; - if (node.state == "notfound") { + if (node.state == Constants::CallTree::StateNotFound) { nameText += " [未找到]"; - } else if (node.state == "cycle") { + } else if (node.state == Constants::CallTree::StateCycle) { nameText += " [循环调用]"; - } else if (node.state == "depthlimit") { + } else if (node.state == Constants::CallTree::StateDepthLimit) { nameText += " [层级截断]"; - } else if (node.state == "supported") { + } else if (node.state == Constants::CallTree::StateSupported) { // 未在转码代码中找到:说明该函数无需转码,原代码已支持 // 功能号通过 uft3 功能查询(按功能名称)获取 - const QString funcNo = lookupUFT3FunctionNo(node.displayName); + const QString funcNo = lookupUFT3FunctionNo(node.displayName, funcNoCache); nameText += " [原代码已支持]"; if (!funcNo.isEmpty()) { nameText = QString("%1-%2").arg(funcNo, nameText); @@ -665,23 +724,19 @@ void CodeSearchPage::addTreeItem(QStandardItem *parentItem, const CallChainAnaly } // 不同状态用不同颜色提示 - if (node.state == "notfound") { + if (node.state == Constants::CallTree::StateNotFound) { nameItem->setForeground(QBrush(QColor(180, 0, 0))); - } else if (node.state == "cycle") { + } else if (node.state == Constants::CallTree::StateCycle) { nameItem->setForeground(QBrush(QColor(180, 100, 0))); - } else if (node.state == "supported") { - // 原代码已支持:整行绿色背景高亮;功能号置于函数名前,格式:功能号-函数名[原代码已支持] - const QString funcNo = lookupUFT3FunctionNo(node.displayName); - if (!funcNo.isEmpty()) { - nameText = QString("%1-%2[原代码已支持]").arg(funcNo, node.displayName); - } else { - nameText += " [原代码已支持]"; - } - const QBrush greenBg(QColor(198, 239, 206)); + } else if (node.state == Constants::CallTree::StateSupported) { + // 原代码已支持:整行绿色背景高亮 + // (功能号已在第一段解析并拼入 nameText, 此处不得重复 lookup—— + // 每次调用 = 精确+模糊两轮 DataCache 全量搜索) + const QBrush greenBg(kHighlightGreenBg); nameItem->setBackground(greenBg); dirItem->setBackground(greenBg); moduleItem->setBackground(greenBg); - nameItem->setForeground(QBrush(QColor(0, 97, 0))); + nameItem->setForeground(QBrush(kHighlightGreenFg)); // 标记高亮角色,交由自定义 delegate 强制绘制绿色背景 nameItem->setData(true, RowHighlightRole); dirItem->setData(true, RowHighlightRole); @@ -693,12 +748,12 @@ void CodeSearchPage::addTreeItem(QStandardItem *parentItem, const CallChainAnaly // 查询的目标函数/功能:整行蓝色背景高亮,突出当前查询节点(仅对非"原代码已支持"节点生效) if (!m_highlightName.isEmpty() && node.displayName == m_highlightName - && node.state != "supported") { - const QBrush blueBg(QColor(189, 215, 238)); + && node.state != Constants::CallTree::StateSupported) { + const QBrush blueBg(kHighlightBlueBg); nameItem->setBackground(blueBg); dirItem->setBackground(blueBg); moduleItem->setBackground(blueBg); - nameItem->setForeground(QBrush(QColor(10, 60, 120))); + nameItem->setForeground(QBrush(kHighlightBlueFg)); // 标记蓝色高亮角色,交由自定义 delegate 强制绘制蓝色背景 nameItem->setData(true, RowHighlightBlueRole); dirItem->setData(true, RowHighlightBlueRole); @@ -710,27 +765,33 @@ void CodeSearchPage::addTreeItem(QStandardItem *parentItem, const CallChainAnaly parentItem->appendRow(row); for (const CallChainAnalyzer::TreeNode &child : node.children) { - addTreeItem(nameItem, child); + addTreeItem(nameItem, child, funcNoCache); } } -QString CodeSearchPage::lookupUFT3FunctionNo(const QString &funcName) +QString CodeSearchPage::lookupUFT3FunctionNo(const QString &funcName, QHash &funcNoCache) { + // 备忘录命中(含"查过但无结果"的空值)直接返回, 避免重复全量扫描 + auto cached = funcNoCache.constFind(funcName); + if (cached != funcNoCache.constEnd()) { + return cached.value(); + } + DataCache *cache = DataCache::instance(); if (!cache || !cache->isDataLoaded()) { + funcNoCache.insert(funcName, QString()); return QString(); } // 先按"功能名称"精确匹配(cname) - QList results = cache->searchUFT3Functions(funcName, "cname", true); + QList results = cache->searchUFT3Functions(funcName, Constants::FunctionSearch::FieldCName, true); if (results.isEmpty()) { // 退化为模糊匹配(cname/英文名/功能号) results = cache->searchUFT3Functions(funcName, "", false); } - if (!results.isEmpty()) { - return results.first().functionNo; - } - return QString(); + const QString funcNo = results.isEmpty() ? QString() : results.first().functionNo; + funcNoCache.insert(funcName, funcNo); + return funcNo; } QString CodeSearchPage::resolveChineseName(const QString &displayName) const @@ -887,14 +948,14 @@ void CodeSearchPage::onQueryByInput() } QString target = raw; - if (m_queryCombo->currentText() == "功能号") { + if (m_queryCombo->currentData() == kQueryModeNo) { // 按功能号解析为对应的函数名(英文名 eName)后再查询 DataCache *cache = DataCache::instance(); if (!cache || !cache->isDataLoaded()) { QMessageBox::information(this, "提示", "功能数据未加载,无法按功能号查询"); return; } - const QList results = cache->searchUFT3Functions(raw, "function_no", true); + const QList results = cache->searchUFT3Functions(raw, Constants::FunctionSearch::FieldFunctionNo, true); if (results.isEmpty() || results.first().eName.isEmpty()) { QMessageBox::information(this, "提示", QString("未找到功能号:%1 对应的函数").arg(raw)); @@ -919,9 +980,10 @@ void CodeSearchPage::runQuery(const QString &displayName) return; } - // 记录发起时的模式: 后台查询运行中模式可能被切换(onPivotClicked 不挡 UI), + // 记录发起时的模式与路径: 后台查询运行中模式可能被切换(onPivotClicked 不挡 UI), // 回调必须写回发起模式的条目, 避免跨模式串写结果树与导出基准路径 const int mode = m_mode; + const QString searchRootPath = m_pathEdit->text().trimmed(); const QString chineseName = resolveChineseName(displayName); // 记录当前查询的函数/功能名,供调用链树中对应行蓝色高亮。 @@ -943,9 +1005,9 @@ void CodeSearchPage::runQuery(const QString &displayName) auto *analyzer = currentAnalyzer(); auto future = QtConcurrent::run([analyzer, chineseName]() -> QueryResult { QueryResult r; - // buildCallTree 内部使用多策略查找,如果未找到根节点会标记为 "notfound" + // buildCallTree 内部使用多策略查找,如果未找到根节点会标记为 notfound r.downTree = analyzer->buildCallTree(chineseName); - if (r.downTree.state == "ok") { + if (r.downTree.state == Constants::CallTree::StateOk) { // 往前查:构造上游调用方森林(每条链以 downTree 收尾),无上游时退化为单根 downTree r.roots = analyzer->buildCallerForest(chineseName, r.downTree); } @@ -953,19 +1015,22 @@ void CodeSearchPage::runQuery(const QString &displayName) }); m_activeWatchers.append(watcher); connect(watcher, &QFutureWatcher::finished, this, - [this, watcher, displayName]() { + [this, watcher, displayName, mode, searchRootPath]() { m_activeWatchers.removeOne(watcher); QApplication::restoreOverrideCursor(); const QueryResult r = watcher->result(); - if (r.downTree.state != "ok") { + // 结果已拷贝到局部 r, 后续不再引用 watcher; 统一在此销毁, + // 避免"未找到"早退路径漏删(失败一次累积一个失效 watcher) + watcher->deleteLater(); + if (r.downTree.state != Constants::CallTree::StateOk) { QMessageBox::information(this, "未找到", QString("未在索引中找到函数:%1\n请确认代码检索路径正确并已刷新索引。").arg(displayName)); return; } const QList roots = r.roots; - populateTree(roots); - m_callTree[m_mode]->expandToDepth(2); + populateTree(roots, mode); + m_callTree[mode]->expandToDepth(2); // 收集调用链路文件(排除"原代码已支持"),并记录导出基准路径与查询名 QSet collected; @@ -974,11 +1039,10 @@ void CodeSearchPage::runQuery(const QString &displayName) } m_currentChainRoots = roots; m_currentChainFilePaths = collected.values(); - m_searchRootPath = m_pathEdit->text().trimmed(); + m_searchRootPath = searchRootPath; m_currentQueryName = displayName; m_importBtn->setEnabled(!m_currentChainFilePaths.isEmpty()); m_exportChainBtn->setEnabled(!m_currentChainFilePaths.isEmpty()); - watcher->deleteLater(); }); watcher->setFuture(future); } @@ -1000,7 +1064,7 @@ void CodeSearchPage::onCollapseAll() void CodeSearchPage::collectChainFiles(const CallChainAnalyzer::TreeNode &node, QSet &out) { // 仅收集有文件且非"原代码已支持"(supported)的节点 - if (node.info && node.state != "supported") { + if (node.info && node.state != Constants::CallTree::StateSupported) { out.insert(node.info->filePath); } for (const CallChainAnalyzer::TreeNode &child : node.children) { @@ -1014,12 +1078,12 @@ void CodeSearchPage::collectChainLines(const CallChainAnalyzer::TreeNode &node, QString indent(depth * 4, QChar(' ')); QString stateTag; - if (node.state == "supported") stateTag = " [原代码已支持]"; - else if (node.state == "notfound") stateTag = " [未找到]"; + if (node.state == Constants::CallTree::StateSupported) stateTag = " [原代码已支持]"; + else if (node.state == Constants::CallTree::StateNotFound) stateTag = " [未找到]"; QString funcLabel = node.displayName; // 为 supported / notfound 节点补充功能号前缀(若可解析且名称中尚未包含) - if ((node.state == "supported" || node.state == "notfound") + if ((node.state == Constants::CallTree::StateSupported || node.state == Constants::CallTree::StateNotFound) && !node.objectId.isEmpty() && !funcLabel.startsWith(node.objectId)) { funcLabel = node.objectId + "-" + funcLabel; } @@ -1083,17 +1147,8 @@ void CodeSearchPage::onImportChainFiles() for (const CallChainAnalyzer::TreeNode &r : m_currentChainRoots) { collectChainLines(r, 0, chainLines); } - const QString txtPath = QDir(queryDir).filePath("_call_chain.txt"); - QSaveFile txtFile(txtPath); - bool chainTxtOk = txtFile.open(QIODevice::WriteOnly | QIODevice::Text); - if (chainTxtOk) { - QTextStream ts(&txtFile); - for (const QString &line : chainLines) { - ts << line << "\n"; - } - ts.flush(); - chainTxtOk = txtFile.commit(); - } + const QString txtPath = QDir(queryDir).filePath(Constants::CodeSearch::ChainFileName); + const bool chainTxtOk = writeChainTextFile(txtPath, chainLines); QString msg = QString("已将 %1 个调用文件导出到:\n%2").arg(copied).arg(QDir::toNativeSeparators(queryDir)); if (!failed.isEmpty()) { @@ -1130,17 +1185,15 @@ void CodeSearchPage::onExportChain() for (const CallChainAnalyzer::TreeNode &r : m_currentChainRoots) { collectChainLines(r, 0, chainLines); } - const QString txtPath = QDir(queryDir).filePath("_call_chain.txt"); - QFile txtFile(txtPath); - if (!txtFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - QMessageBox::warning(this, "提示", "调用链路文件写入失败"); + const QString txtPath = QDir(queryDir).filePath(Constants::CodeSearch::ChainFileName); + if (!writeChainTextFile(txtPath, chainLines)) { + // 打开或提交失败必须显式告知: 用户依赖 _call_chain.txt 排查调用链路 + QMessageBox::warning(this, "提示", + QString("调用链路文件写入失败(磁盘空间不足/目录只读?):\n%1") + .arg(QDir::toNativeSeparators(txtPath))); + LogManager::instance()->logError("调用链路文本写入失败: " + txtPath); return; } - QTextStream ts(&txtFile); - for (const QString &line : chainLines) { - ts << line << "\n"; - } - txtFile.close(); QString msg = QString("已导出调用链路(%1 行)到:\n%2") .arg(chainLines.size()).arg(QDir::toNativeSeparators(queryDir)); diff --git a/src/pages/codesearch/codesearchpage.h b/src/pages/codesearch/codesearchpage.h index 1da510a..3ace871 100644 --- a/src/pages/codesearch/codesearchpage.h +++ b/src/pages/codesearch/codesearchpage.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -39,7 +40,10 @@ signals: protected: void mousePressEvent(QMouseEvent *event) override { - emit clicked(); + // 仅左键触发, 右键(上下文菜单等)不误发信号 + if (event->button() == Qt::LeftButton) { + emit clicked(); + } QLabel::mousePressEvent(event); } }; @@ -81,9 +85,10 @@ private: void loadFunctionList(); void populateTree(const QList &rootNodes, int mode = -1); void autoResizeColumns(ElaTreeView *tree); - void addTreeItem(QStandardItem *parentItem, const CallChainAnalyzer::TreeNode &node); + void addTreeItem(QStandardItem *parentItem, const CallChainAnalyzer::TreeNode &node, + QHash &funcNoCache); QString resolveChineseName(const QString &displayName) const; - QString lookupUFT3FunctionNo(const QString &funcName); + QString lookupUFT3FunctionNo(const QString &funcName, QHash &funcNoCache); void collectChainFiles(const CallChainAnalyzer::TreeNode &node, QSet &out); static void collectChainLines(const CallChainAnalyzer::TreeNode &node, int depth, QStringList &lines); static QString sanitizeDirName(const QString &name); @@ -135,6 +140,7 @@ private: QString m_currentQueryName; // 当前查询的函数名称(导出目录名) QString m_highlightName; // 当前查询的函数/功能名(调用链树中对应行蓝色高亮) bool m_indexing = false; // 后台索引构建进行中,防止重复触发 + qint64 m_lastCustMtime = -1; // cust.json 上次加载函数列表时的 mtime(-1=未加载) // 在途后台任务的 watcher: 任务通过裸指针访问 m_analyzers, 页面析构时必须等待完成防止 use-after-free。 // 存 watcher 指针而非 QFuture 值: QFuture 无 operator== 无法从列表移除, 指针比较即可 QList m_activeWatchers; diff --git a/src/pages/common/convertworkbenchbase.cpp b/src/pages/common/convertworkbenchbase.cpp new file mode 100644 index 0000000..5f9dfa1 --- /dev/null +++ b/src/pages/common/convertworkbenchbase.cpp @@ -0,0 +1,664 @@ +#include "src/pages/common/convertworkbenchbase.h" +#include "src/utils/Constants.h" +#include "src/utils/datacache.h" +#include "src/utils/uf2configreader.h" +#include "src/utils/logmanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ElaPushButton.h" +#include "ElaTheme.h" + +namespace { + +// "是否存在"列(两工作台一致)与操作列固定宽度 +constexpr int ExistsColumn = 1; +constexpr int ActionColumnWidth = 80; +// 开始/停止按钮样式: 状态切换仅换底色, 其余属性一致; 常量化防多处字面量漂移 +constexpr const char *kStartBtnStyle = + "background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"; +constexpr const char *kStopBtnStyle = + "background-color: #e74c3c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"; + +// 状态列自绘委托: 读取 Background/Foreground Role 上色后绘制文字 +class StatusDelegate : public QStyledItemDelegate +{ +public: + explicit StatusDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + QVariant bgColor = index.data(Qt::BackgroundRole); + QVariant fgColor = index.data(Qt::ForegroundRole); + + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + + if (bgColor.canConvert()) { + painter->fillRect(opt.rect, bgColor.value()); + } + + if (fgColor.canConvert()) { + painter->setPen(fgColor.value().color()); + } + + painter->drawText(opt.rect, opt.displayAlignment, opt.text); + } +}; + +// 统一状态列样式: 文案 + 对齐 + 存在/缺失双色 +void styleStatusItem(QStandardItem *item, bool exists) +{ + item->setText(exists ? "是" : "否"); + item->setTextAlignment(Qt::AlignCenter); + if (exists) { + item->setData(QBrush(QColor(144, 238, 144)), Qt::BackgroundRole); + item->setData(QBrush(QColor(0, 100, 0)), Qt::ForegroundRole); + } else { + item->setData(QBrush(QColor(255, 182, 193)), Qt::BackgroundRole); + item->setData(QBrush(QColor(139, 0, 0)), Qt::ForegroundRole); + } +} + +} // namespace + +ConvertWorkbenchBase::ConvertWorkbenchBase(QWidget *parent) + : QWidget(parent) +{ + // 两阶段初始化: 界面装配由子类构造函数显式调用 initialize() 完成, + // 见头文件说明(构造期间虚钩子派发不到子类实现) +} + +void ConvertWorkbenchBase::initialize() +{ + initUI(); + loadConfig(); + + // 外部修改监控: 双监控+防抖+mtime 回环过滤统一由 JsonFileMonitor 处理, + // reloadRequested -> loadConfig 为唯一的外部数据回读路径 + m_configMonitor = new JsonFileMonitor(this); + connect(m_configMonitor, &JsonFileMonitor::reloadRequested, + this, &ConvertWorkbenchBase::loadConfig); + m_configMonitor->watch(configFilePath()); + + // 灰字说明/进度标签跟随主题明暗切换 + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &ConvertWorkbenchBase::updateTipStyle); + updateTipStyle(); + + // 功能缓存预热完成信号订阅: 元数据更新/启动预热就绪后立即补刷"是否存在"列。 + // 无订阅时预热完成后没有任何刷新时机——预热在途窗口内切页 showEvent 走 + // refreshExistColumn 的 cname 空集提前返回(状态列保留旧值), 预热完成事件又被 + // 丢弃, 状态只能等下一次切页才更新, 表现为"元数据更新后业务转码状态迟迟不刷新"。 + // refreshExistColumn 经 cname 缓存路径接管预热结果后批量重算, 未就绪时空集早退, + // 幂等无副作用; UF20/UFT3 任一库就绪都会触发, 非本页数据源的刷新为空集早退 + connect(DataCache::instance(), &DataCache::functionCacheReady, + this, &ConvertWorkbenchBase::refreshExistColumn); +} + +void ConvertWorkbenchBase::initUI() +{ + QVBoxLayout *layout = new QVBoxLayout(this); + applyPageLayout(layout); + + QGroupBox *funcBox = new QGroupBox("转码功能"); + QVBoxLayout *funcLayout = new QVBoxLayout(funcBox); + funcLayout->setSpacing(10); + + m_tableModel = new QStandardItemModel(this); + m_tableModel->setColumnCount(tableColumnCount()); + m_tableModel->setHorizontalHeaderLabels(tableHeaderLabels()); + + m_funcTable = new ElaTableView; + m_funcTable->setModel(m_tableModel); + m_funcTable->setMinimumHeight(tableMinimumHeight()); + m_funcTable->setSelectionBehavior(QAbstractItemView::SelectRows); + for (int col = 0; col < tableColumnCount(); ++col) { + m_funcTable->horizontalHeader()->setSectionResizeMode(col, QHeaderView::Fixed); + } + m_funcTable->horizontalHeader()->setStretchLastSection(false); + m_funcTable->setItemDelegateForColumn(ExistsColumn, new StatusDelegate(this)); + funcLayout->addWidget(m_funcTable); + + QHBoxLayout *tableBtnLayout = new QHBoxLayout; + ElaPushButton *addBtn = new ElaPushButton("添加"); + connect(addBtn, &QPushButton::clicked, this, &ConvertWorkbenchBase::onAddFunction); + ElaPushButton *removeBtn = new ElaPushButton("删除选中"); + connect(removeBtn, &QPushButton::clicked, this, &ConvertWorkbenchBase::onRemoveFunction); + ElaPushButton *clearBtn = new ElaPushButton("清空列表"); + connect(clearBtn, &QPushButton::clicked, this, &ConvertWorkbenchBase::onClearTable); + tableBtnLayout->addWidget(addBtn); + tableBtnLayout->addStretch(); + tableBtnLayout->addWidget(removeBtn); + tableBtnLayout->addWidget(clearBtn); + funcLayout->addLayout(tableBtnLayout); + + layout->addWidget(funcBox); + + QGroupBox *progressBox = new QGroupBox("转换进度"); + progressBox->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + QVBoxLayout *progressLayout = new QVBoxLayout(progressBox); + progressLayout->setSpacing(10); + + m_progressLabel = new ElaText("等待开始..."); + m_progressLabel->setFixedHeight(40); + m_progressLabel->setWordWrap(false); + m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + progressLayout->addWidget(m_progressLabel); + + m_progressBar = new ElaProgressBar; + m_progressBar->setRange(0, 100); + m_progressBar->setValue(0); + m_progressBar->setFixedHeight(25); + m_progressBar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + progressLayout->addWidget(m_progressBar); + + layout->addWidget(progressBox); + + QHBoxLayout *btnLayout = new QHBoxLayout; + m_startBtn = new QPushButton("开始转换"); + m_startBtn->setStyleSheet(kStartBtnStyle); + connect(m_startBtn, &QPushButton::clicked, this, &ConvertWorkbenchBase::onStartConvert); + btnLayout->addStretch(); + btnLayout->addWidget(m_startBtn); + layout->addLayout(btnLayout); +} + +QStandardItem *ConvertWorkbenchBase::createNameItem(const QString &funcName) +{ + return new QStandardItem(funcName); +} + +void ConvertWorkbenchBase::refreshTable() +{ + // 不使用 blockSignals:QTableView 依赖 rowsRemoved/rowsInserted 信号更新内部布局缓存, + // 若屏蔽会导致新增行不可见。 + // 批量重建期间抑制逐行重绘:N 行的 removeRow/appendRow/setIndexWidget 每步都会触发 + // 视图 layout/repaint,关闭更新后仅在重建完成时整体重绘一次 + m_funcTable->setUpdatesEnabled(false); + m_tableModel->removeRows(0, m_tableModel->rowCount()); + + // 批量存在性判定: 一次取全量 cname 集合替代逐行单条查库(cname 缓存未就绪窗口期 + // 每行都会触发一次 warmup 探测 + 单条 SQL)。集合为空(数据未落库/缓存未就绪)时 + // 回退逐行 checkFunctionExists——其内部含单条查库兜底, 不会把"未就绪"误判为"不存在" + const QSet allCnames = UF2ConfigReader::getAllCnamesStatic(); + const bool batchExistsReady = !allCnames.isEmpty(); + + for (int i = 0; i < m_funcList.size(); ++i) { + const QString funcName = m_funcList[i]; + + QStandardItem *nameItem = createNameItem(funcName); + nameItem->setFlags(nameItem->flags() & ~Qt::ItemIsEditable); + + QStandardItem *statusItem = new QStandardItem; + statusItem->setFlags(statusItem->flags() & ~Qt::ItemIsEditable); + styleStatusItem(statusItem, batchExistsReady ? allCnames.contains(funcName) + : checkFunctionExists(funcName)); + + QStandardItem *actionItem = new QStandardItem(""); + actionItem->setFlags(actionItem->flags() & ~Qt::ItemIsEditable); + + QList rowItems; + rowItems.append(nameItem); + rowItems.append(statusItem); + attachRowItems(rowItems, funcName); + rowItems.append(actionItem); + m_tableModel->appendRow(rowItems); + + attachRowWidgets(i, funcName); + + QPushButton *editBtn = new QPushButton; + editBtn->setIcon(QIcon(":/resources/images/bianji.png")); + editBtn->setIconSize(QSize(20, 20)); + editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;"); + editBtn->setToolTip("修改"); + // QueuedConnection: onEditRow 内 refreshTable 会重建表格析构 editBtn 自身, + // 同步回调返回到按钮内部事件处理时构成 use-after-free, 故延迟到事件循环执行。 + // 捕获函数名而非行号且以按钮为接收者: 排队前表格可能被重建, 行号会指向错误行; + // 函数名是稳定键, 且按钮析构时过期的排队请求随之失效 + connect(editBtn, &QPushButton::clicked, editBtn, [this, funcName]() { + onEditRowByName(funcName); + }, Qt::QueuedConnection); + m_funcTable->setIndexWidget(m_tableModel->index(i, m_tableModel->columnCount() - 1), editBtn); + } + + applyColumnLayout(); + + // 恢复视图更新并整体重绘一次 + m_funcTable->setUpdatesEnabled(true); + + // 视图刷新保险:强制重绘并滚动到顶部,确保新增行可见 + m_funcTable->viewport()->update(); + m_funcTable->resizeRowsToContents(); + m_funcTable->scrollToTop(); + m_funcTable->clearSelection(); +} + +void ConvertWorkbenchBase::refreshExistColumn() +{ + if (!m_tableModel) { + return; + } + const int rows = qMin(m_funcList.size(), m_tableModel->rowCount()); + // 一次取全量 cname 集合批量判定。与 refreshTable 的"空集合逐行单条查库兜底"不同: + // 本函数由 showEvent 切页路径高频触发, 缓存未就绪(预热中/数据未落库)时逐行查库 + // 会产生 N 次单条 SQL, 故未就绪时本次直接跳过(状态列保留上次结果), + // 预热完成后下次切页自动切换为内存集合快路径 + const QSet allCnames = UF2ConfigReader::getAllCnamesStatic(); + if (allCnames.isEmpty()) { + return; + } + for (int i = 0; i < rows; ++i) { + QStandardItem *item = m_tableModel->item(i, ExistsColumn); + if (!item) { + continue; + } + styleStatusItem(item, allCnames.contains(m_funcList[i])); + } +} + +bool ConvertWorkbenchBase::checkFunctionExists(const QString &funcName) +{ + return UF2ConfigReader::checkFuncExistsStatic(funcName); +} + +void ConvertWorkbenchBase::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (m_funcTable) { + applyColumnLayout(); + } +} + +void ConvertWorkbenchBase::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + // 构造期 loadConfig 计算列宽时页面尚未布局, viewport 宽度非真实值, + // 首次切入本页时宽度已就绪, 重算一次保证列宽比例正确(幂等, 开销可忽略) + applyColumnLayout(); + // 元数据更新后 DataCache 可能变化: 切页回来仅轻量刷新"是否存在"列, + // 不做全量表格重建(数据变更已由 refreshTable/外部修改监控统一触发) + refreshExistColumn(); +} + +void ConvertWorkbenchBase::onProgressUpdate(int progress, const QString &status) +{ + m_progressBar->setValue(progress); + + // 仅当传入有效状态文字时才更新标签,避免无意义的日志行覆盖当前处理状态 + if (!status.isEmpty()) { + QString displayStatus = status; + if (displayStatus.length() > 80) { + displayStatus = displayStatus.left(80) + "..."; + } + m_progressLabel->setText(displayStatus); + } +} + +// 按函数名定位行再编辑: 表格可能在外部文件监控/切页时被全量重建, +// 排队请求中的旧行号会失效指向错误行, 函数名是唯一稳定键 +void ConvertWorkbenchBase::onEditRowByName(const QString &funcName) +{ + const int row = m_funcList.indexOf(funcName); + if (row >= 0) { + onEditRow(row); + } +} + +void ConvertWorkbenchBase::onEditRow(int row) +{ + if (row < 0 || row >= m_funcList.size()) { + return; + } + + QString oldName = m_funcList[row]; + QString newName = oldName; + + while (true) { + QInputDialog dialog(this); + dialog.setWindowTitle("修改函数名"); + dialog.setLabelText("请输入新的函数名:"); + dialog.setTextValue(oldName); + dialog.resize(300, 150); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + newName = normalizeFunctionName(dialog.textValue()); + + if (newName.isEmpty()) { + QMessageBox::warning(this, "提示", "函数名不能为空!"); + continue; + } + + if (!validateFunctionNames(QStringList{newName})) { + continue; + } + + if (newName == oldName) { + return; + } + + if (m_funcList.contains(newName)) { + QMessageBox::warning(this, "提示", "该函数名已存在,请重新输入!"); + continue; + } + + break; + } + + onFunctionsRenamed(oldName, newName); + m_funcList[row] = newName; + + // 单一刷新入口: 改内存 -> 落盘 -> 一次 refreshTable, 不再经 loadFromXxx 回读二次重建 + if (!saveConfig(m_funcList)) { + QMessageBox::warning(this, "保存失败", + QString("函数名已在内存中修改,但写入 %1 失败(文件被占用或权限不足?),重启后修改将丢失!") + .arg(configDisplayName())); + } + refreshTable(); + adviseAddedFunctions(QStringList{newName}); +} + +void ConvertWorkbenchBase::onAddFunction() +{ + QDialog dialog(this); + dialog.setWindowTitle(addDialogTitle()); + dialog.resize(400, 250); + + QVBoxLayout *layout = new QVBoxLayout(&dialog); + + QTextEdit *textEdit = new QTextEdit; + textEdit->setPlaceholderText(addDialogPlaceholder()); + layout->addWidget(textEdit); + + QHBoxLayout *btnLayout = new QHBoxLayout; + QPushButton *okBtn = new QPushButton("确定"); + QPushButton *cancelBtn = new QPushButton("取消"); + btnLayout->addStretch(); + btnLayout->addWidget(okBtn); + btnLayout->addWidget(cancelBtn); + layout->addLayout(btnLayout); + + // 确定: 经合法性校验通过才关闭对话框(校验失败由子类弹窗说明) + connect(okBtn, &QPushButton::clicked, &dialog, [this, &dialog, textEdit]() { + const QStringList funcNames = + textEdit->toPlainText().split('\n', Qt::SkipEmptyParts); + if (validateFunctionNames(funcNames)) { + dialog.accept(); + } + }); + connect(cancelBtn, &QPushButton::clicked, &dialog, &QDialog::reject); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + if (textEdit->toPlainText().trimmed().isEmpty()) { + return; + } + + const QStringList funcNames = + textEdit->toPlainText().split('\n', Qt::SkipEmptyParts); + + // 公共核心: 清洗 + 判重 + 登记(差异化弹窗/落盘/刷新在下方) + const QPair addResult = addNames(funcNames); + const QStringList &addedNames = addResult.first; + const int addedCount = addedNames.size(); + const int existCount = addResult.second; + + if (existCount > 0) { + QMessageBox::information(this, "提示", + QString("成功添加 %1 个函数,%2 个函数已存在").arg(addedCount).arg(existCount)); + } else if (addedCount > 0) { + QMessageBox::information(this, "提示", + QString("成功添加 %1 个函数").arg(addedCount)); + } + + adviseAddedFunctions(addedNames); + + // 单一刷新入口: 仅当数据实际变化时落盘 + 一次 refreshTable + if (addedCount > 0) { + if (!saveConfig(m_funcList)) { + QMessageBox::warning(this, "保存失败", + QString("新增函数已在内存中生效,但写入 %1 失败(文件被占用或权限不足?),重启后将丢失!") + .arg(configDisplayName())); + } + refreshTable(); + } +} + +void ConvertWorkbenchBase::onRemoveFunction() +{ + QModelIndexList selectedIndexes = m_funcTable->selectionModel()->selectedRows(); + if (selectedIndexes.isEmpty()) { + QMessageBox::warning(this, "提示", "请先选中要删除的行!"); + return; + } + + QList rowsToRemove; + for (const QModelIndex &index : selectedIndexes) { + int row = index.row(); + if (!rowsToRemove.contains(row)) { + rowsToRemove.append(row); + } + } + + // 先收集待删函数名交子类附加动作(如清理"是否递归"映射), 再统一删除 + QStringList names; + for (int row : rowsToRemove) { + names.append(m_funcList.at(row)); + } + onFunctionsRemoved(names); + + std::sort(rowsToRemove.begin(), rowsToRemove.end(), std::greater()); + for (int row : rowsToRemove) { + m_funcList.removeAt(row); + } + + if (!saveConfig(m_funcList)) { + QMessageBox::warning(this, "保存失败", + QString("删除操作已在内存中生效,但写入 %1 失败(文件被占用或权限不足?),重启后将恢复原清单!") + .arg(configDisplayName())); + } + refreshTable(); +} + +void ConvertWorkbenchBase::onClearTable() +{ + if (m_funcList.isEmpty()) { + return; + } + + QMessageBox::StandardButton reply; + reply = QMessageBox::question(this, "确认清空", "确定要清空所有函数吗?", + QMessageBox::Yes | QMessageBox::No); + if (reply == QMessageBox::Yes) { + m_funcList.clear(); + onFunctionListCleared(); + + if (!saveConfig(m_funcList)) { + QMessageBox::warning(this, "保存失败", + QString("清空操作已在内存中生效,但写入 %1 失败(文件被占用或权限不足?),重启后将恢复原清单!") + .arg(configDisplayName())); + } + refreshTable(); + } +} + +void ConvertWorkbenchBase::onStartConvert() +{ + if (m_converting) { + m_progressLabel->setText("正在停止转换..."); + emit stopConvert(); + return; + } + + if (m_funcList.isEmpty()) { + QMessageBox::warning(this, "提示", "请先添加需要转码的函数!"); + return; + } + + m_progressBar->setValue(0); + m_progressLabel->setText("正在准备转换..."); + + if (!saveConfig(m_funcList)) { + m_progressLabel->setText("配置保存失败"); + return; + } + + if (!prepareStartArguments()) { + m_progressLabel->setText("参数生成失败"); + return; + } + + m_progressBar->setValue(20); + m_progressLabel->setText("配置加载成功,开始转换..."); + + emit startConvert(); +} + +void ConvertWorkbenchBase::onConvertStarted() +{ + m_converting = true; + m_startBtn->setText("停止转换"); + m_startBtn->setStyleSheet(kStopBtnStyle); +} + +void ConvertWorkbenchBase::onConvertFinished() +{ + m_converting = false; + m_progressBar->setValue(100); + m_progressLabel->setText("转换完成"); + m_startBtn->setText("开始转换"); + m_startBtn->setStyleSheet(kStartBtnStyle); +} + +void ConvertWorkbenchBase::onConvertFailed(const QString &detail) +{ + m_converting = false; + // 失败收尾: 进度条保持当前进度(不复位 100%, 与成功完成区分), 文字提示转换失败 + m_progressLabel->setText(detail.isEmpty() ? "转换失败" : detail); + m_startBtn->setText("开始转换"); + m_startBtn->setStyleSheet(kStartBtnStyle); +} + +void ConvertWorkbenchBase::setTaskBusy(bool busy) +{ + if (!m_startBtn) { + return; + } + m_startBtn->setEnabled(!busy); + if (busy) { + m_progressBar->setValue(0); + m_progressLabel->setText("有其他转换任务进行中,请等待完成..."); + } else { + m_progressLabel->setText("等待开始..."); + } +} + +// 添加流程公共核心: 名称清洗 + QSet 判重(含同批互判) + onFunctionAdded 附加动作。 +// 返回 {新增名列表, 跳过数}; 不落盘/不刷新/不弹窗, 差异化交互由调用方处理 +QPair ConvertWorkbenchBase::addNames(const QStringList &names) +{ + QStringList added; + int skipped = 0; + // QSet 判重替代 QList::contains 线性查找(批量输入 O(n·m) → O(n+m)) + QSet existing(m_funcList.cbegin(), m_funcList.cend()); + for (const QString &funcName : names) { + const QString name = normalizeFunctionName(funcName); + if (name.isEmpty()) { + continue; + } + if (existing.contains(name)) { + ++skipped; + continue; + } + existing.insert(name); + m_funcList.append(name); + onFunctionAdded(name); + added.append(name); + } + return qMakePair(added, skipped); +} + +void ConvertWorkbenchBase::addFunction(const QString &funcName) +{ + if (funcName.isEmpty()) { + return; + } + + const QString name = normalizeFunctionName(funcName); + if (name.isEmpty()) { + return; + } + + const QPair addResult = addNames(QStringList{name}); + if (addResult.first.isEmpty()) { + QMessageBox::information(nullptr, "提示", QString("功能 [%1] 已在列表中").arg(name)); + return; + } + + refreshTable(); + + if (saveConfig(m_funcList)) { + QMessageBox::information(nullptr, "提示", + QString("已将功能 [%1] 添加到\"%2\"").arg(name).arg(workbenchTitle())); + } else { + QMessageBox::warning(nullptr, "警告", + QString("添加到\"%1\"成功,但保存到 %2 失败").arg(workbenchTitle()).arg(configDisplayName())); + } +} + +QPair ConvertWorkbenchBase::addFunctionsBatch(const QStringList &funcNames, bool notify) +{ + // 批量添加(功能查询页多选双击): 不逐条弹窗; + // notify=true 时结束后一次汇总提示, notify=false 静默(由调用方统一汇总), 返回 {新增数, 跳过数} + const QPair addResult = addNames(funcNames); + const QStringList &added = addResult.first; + const int skipped = addResult.second; + + if (added.isEmpty()) { + if (notify && skipped > 0) { + QMessageBox::information(nullptr, "提示", + QString("所选 %1 个功能均已在\"%2\"列表中").arg(skipped).arg(workbenchTitle())); + } + return qMakePair(0, skipped); + } + + refreshTable(); + if (!saveConfig(m_funcList)) { + QMessageBox::warning(nullptr, "警告", + QString("添加成功,但保存到 %1 失败(%2 个功能)").arg(configDisplayName()).arg(added.size())); + return qMakePair(-1, -1); + } + if (notify) { + QMessageBox::information(nullptr, "提示", + QString("已将 %1 个功能加入\"%2\"").arg(added.size()).arg(workbenchTitle()) + + (skipped > 0 ? QString("(另有 %1 个已存在,已跳过)").arg(skipped) : QString())); + } + return qMakePair(added.size(), skipped); +} + +void ConvertWorkbenchBase::updateTipStyle() +{ + const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); + const QString textColor = ElaThemeColor(mode, BasicText).name(); + if (m_progressLabel) { + // 仅联动颜色: ElaText 保持默认字号, 强设 12px 过小(用户反馈) + m_progressLabel->setStyleSheet(QString("color: %1;").arg(textColor)); + } + applyTipStyle(textColor); +} diff --git a/src/pages/common/convertworkbenchbase.h b/src/pages/common/convertworkbenchbase.h new file mode 100644 index 0000000..3d64e0e --- /dev/null +++ b/src/pages/common/convertworkbenchbase.h @@ -0,0 +1,150 @@ +#ifndef CONVERTWORKBENCHBASE_H +#define CONVERTWORKBENCHBASE_H + +#include +#include +#include +#include +#include "ElaTableView.h" +#include "ElaProgressBar.h" +#include "ElaText.h" +#include "utils/jsonfilemonitor.h" + +class QVBoxLayout; +class QResizeEvent; +class QShowEvent; +class QStandardItem; + +// 转码工作台公共基类(全量转码 BatchConvertPage / 按需转码 NonLsConvertPage) +// 统一收敛: +// - 表格/进度条/开始按钮/外部修改监控/主题联动的公共界面骨架 +// - 增删改查与批量添加的公共交互流程,经名称清洗/合法性校验/附加动作虚钩子差异化 +// - 单一刷新入口 refreshTable(): 数据变更路径统一为 改内存 -> saveConfig -> refreshTable, +// 不再经 loadFromXxx 回读配置触发二次表格重建 +// - showEvent 仅轻量刷新"是否存在"列(元数据更新后 DataCache 可能变化), 不做全量表格重建 +// - 外部修改监控 reloadRequested -> loadConfig 为唯一的外部数据回读路径 +// 两阶段初始化: 基类构造函数不做界面装配, 由子类构造函数显式调用 initialize()。 +// 原因: 基类构造期间动态类型是基类, loadConfig/applyPageLayout 等虚钩子派发不到子类实现 +// (GCC -Wpure-virtual-call); 子类构造体内调用 initialize() 时所有子类成员均已构造完成。 +class ConvertWorkbenchBase : public QWidget +{ + Q_OBJECT + +public: + explicit ConvertWorkbenchBase(QWidget *parent = nullptr); + +signals: + void startConvert(); + void stopConvert(); + +public slots: + void onProgressUpdate(int progress, const QString &status); + void onConvertStarted(); + void onConvertFinished(); + // 失败收尾: 由 MainWindow 在引擎异常退出或检测到 FAIL 行时调用(见 onPythonRunnerFinished) + void onConvertFailed(const QString &detail); + void addFunction(const QString &funcName); + // 批量添加(功能查询页多选双击): 不逐条弹窗, 结束后一次汇总提示; + // notify=false 为静默模式(不弹提示框), 返回 {新增数, 跳过数}, 保存失败返回 {-1, -1}(已弹警告) + QPair addFunctionsBatch(const QStringList &funcNames, bool notify = true); + // 另一工作台任务运行时由 MainWindow 调用,禁用本页开始按钮 + void setTaskBusy(bool busy); + + int getFunctionCount() const { return m_funcList.size(); } + // 本次开始转换前已拼装好的引擎参数(由 prepareStartArguments 钩子生成,默认空) + QStringList preparedArguments() const { return m_preparedArgs; } + +protected: + // ---- 界面骨架虚钩子 ---- + // 页面级布局参数(边距/间距)与页首说明条的插入 + virtual void applyPageLayout(QVBoxLayout *layout) = 0; + virtual int tableColumnCount() const = 0; + virtual QStringList tableHeaderLabels() const = 0; + virtual int tableMinimumHeight() const { return 200; } + // 列宽布局: refreshTable 尾部与 resizeEvent 统一经此入口 + virtual void applyColumnLayout() = 0; + // 主题联动: 子类额外控件(如页首灰字说明条)统一着色 + virtual void applyTipStyle(const QString &textColor) { Q_UNUSED(textColor) } + + // ---- 名称清洗/校验/附加动作虚钩子 ---- + // 名称清洗钩子: 子类可剥离误带的后缀等(默认仅去首尾空白) + virtual QString normalizeFunctionName(const QString &raw) const { return raw.trimmed(); } + // 名称合法性校验(可弹窗说明): 通过返回 true, 失败返回 false(默认全部通过) + virtual bool validateFunctionNames(const QStringList &names) { Q_UNUSED(names) return true; } + // 单个/批量新增后的子类附加动作(如登记"是否递归"映射, 默认无) + virtual void onFunctionAdded(const QString &name) { Q_UNUSED(name) } + virtual void onFunctionsRemoved(const QStringList &names) { Q_UNUSED(names) } + virtual void onFunctionsRenamed(const QString &oldName, const QString &newName) + { Q_UNUSED(oldName) Q_UNUSED(newName) } + virtual void onFunctionListCleared() {} + // 新增函数后的建议提示(如 LS_ 前缀建议去全量转码, 默认无) + virtual void adviseAddedFunctions(const QStringList &names) { Q_UNUSED(names) } + + // ---- 表格行虚钩子 ---- + // 函数名列样式(子类可对特殊前缀着色等) + virtual QStandardItem *createNameItem(const QString &funcName); + // 在"是否存在"与"操作"列之间插入额外占位项(如"是否递归"列, 默认无) + virtual void attachRowItems(QList &rowItems, const QString &funcName) + { Q_UNUSED(rowItems) Q_UNUSED(funcName) } + // 嵌入额外列控件(如"是否递归"下拉, 默认无); 操作列编辑按钮由基类统一嵌入 + virtual void attachRowWidgets(int row, const QString &funcName) + { Q_UNUSED(row) Q_UNUSED(funcName) } + + // ---- 持久化与启动准备虚钩子 ---- + virtual bool saveConfig(const QStringList &funcList) = 0; + // 从配置加载(子类赋值 m_funcList 后调用 refreshTable()) + virtual void loadConfig() = 0; + virtual QString configFilePath() const = 0; + // 工作台显示名(消息文案用, 如"全量转码"/"按需转码") + virtual QString workbenchTitle() const = 0; + // 配置文件显示名(失败告警文案用, 如"cust.json"/"cust_nonls.json") + virtual QString configDisplayName() const = 0; + virtual QString addDialogTitle() const = 0; + virtual QString addDialogPlaceholder() const = 0; + // 开始转换准备: 保存配置成功后、emit startConvert 前调用; 返回 false 中止(默认无) + virtual bool prepareStartArguments() { return true; } + + // ---- 单一刷新入口 ---- + // 表格全量重建的唯一入口: 所有数据变更路径统一经此刷新 + void refreshTable(); + // 轻量刷新: 仅重算"是否存在"列(showEvent 用), 不重建表格与行控件 + void refreshExistColumn(); + // 函数存在性检查(读 DataCache) + bool checkFunctionExists(const QString &funcName); + + // ---- 两阶段初始化: 子类构造函数体内显式调用 ---- + // 装配公共界面骨架 -> loadConfig 加载清单 -> 挂外部修改监控与主题联动 + void initialize(); + + // 子类工作台共用状态(经虚钩子读写) + ElaTableView *m_funcTable = nullptr; + QStandardItemModel *m_tableModel = nullptr; + QStringList m_funcList; + JsonFileMonitor *m_configMonitor = nullptr; // 外部修改监控(双监控+防抖+mtime 回环过滤) + QStringList m_preparedArgs; + +private slots: + void onStartConvert(); + void onAddFunction(); + void onRemoveFunction(); + void onClearTable(); + void onEditRow(int row); + void onEditRowByName(const QString &funcName); + void updateTipStyle(); // 灰字说明/进度标签跟随主题明暗切换 + +private: + void initUI(); + void resizeEvent(QResizeEvent *event) override; + void showEvent(QShowEvent *event) override; + + // 添加流程公共核心: 名称清洗 + QSet 判重(含同批互判) + onFunctionAdded 附加动作。 + // 返回 {新增名列表, 跳过数}; 不落盘/不刷新/不弹窗, 差异化交互由调用方处理 + QPair addNames(const QStringList &names); + ElaProgressBar *m_progressBar = nullptr; + ElaText *m_progressLabel = nullptr; + QPushButton *m_startBtn = nullptr; + // 转换运行态标志: 判运行态用状态位而非按钮文本(文本判据在文案/样式调整时静默失效) + bool m_converting = false; +}; + +#endif diff --git a/src/pages/datamapping/functionmappingpage.cpp b/src/pages/datamapping/functionmappingpage.cpp index 2ed2bc5..09132d6 100644 --- a/src/pages/datamapping/functionmappingpage.cpp +++ b/src/pages/datamapping/functionmappingpage.cpp @@ -1,588 +1,9 @@ #include "functionmappingpage.h" #include "utils/Constants.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "src/utils/logmanager.h" -#include "ElaPushButton.h" -#include "ElaLineEdit.h" -#include "ElaText.h" -#include "ElaTheme.h" -#include -#include -#include - -// 自定义代理模型:支持两列同时过滤 -class FuncFilterProxyModel : public QSortFilterProxyModel -{ -public: - explicit FuncFilterProxyModel(QObject *parent = nullptr) : QSortFilterProxyModel(parent) {} - - void setFilterText(const QString &text) - { - m_filterText = text; - invalidateFilter(); - } - -protected: - bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override - { - if (m_filterText.isEmpty()) return true; - QString t0 = sourceModel()->data(sourceModel()->index(sourceRow, 0, sourceParent)).toString(); - QString t1 = sourceModel()->data(sourceModel()->index(sourceRow, 1, sourceParent)).toString(); - return t0.contains(m_filterText, Qt::CaseInsensitive) || t1.contains(m_filterText, Qt::CaseInsensitive); - } - -private: - QString m_filterText; -}; FunctionMappingPage::FunctionMappingPage(QWidget *parent) - : QWidget(parent) - , m_isLoading(false) - , m_isSaving(false) + : TwoColumnKVMappingPage(Constants::CustKeys::FunctionReplace, + "函数", "函数名", parent) { - initUI(); - loadData(); -} - -FunctionMappingPage::~FunctionMappingPage() -{ -} - -void FunctionMappingPage::initUI() -{ - QVBoxLayout *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(20, 20, 20, 20); - mainLayout->setSpacing(10); - - // 搜索框 - QHBoxLayout *searchLayout = new QHBoxLayout; - ElaLineEdit *searchEdit = new ElaLineEdit(this); - searchEdit->setPlaceholderText("搜索 UF20 或 UFT3 函数名..."); - searchEdit->setClearButtonEnabled(true); - connect(searchEdit, &ElaLineEdit::textChanged, this, &FunctionMappingPage::onSearchTextChanged); - searchLayout->addWidget(searchEdit); - mainLayout->addLayout(searchLayout); - - m_model = new QStandardItemModel(this); - m_model->setColumnCount(2); - m_model->setHorizontalHeaderLabels({"UF20", "UFT3"}); - connect(m_model, &QStandardItemModel::itemChanged, this, &FunctionMappingPage::onModelItemChanged); - - m_proxyModel = new FuncFilterProxyModel(this); - m_proxyModel->setSourceModel(m_model); - - m_tableView = new ElaTableView; - m_tableView->setModel(m_proxyModel); - m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); - m_tableView->setSelectionMode(QAbstractItemView::ExtendedSelection); - m_tableView->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - m_tableView->verticalHeader()->setVisible(false); - m_tableView->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); - m_tableView->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); - mainLayout->addWidget(m_tableView); - - QHBoxLayout *btnLayout = new QHBoxLayout; - ElaPushButton *addBtn = new ElaPushButton("添加", this); - ElaPushButton *delBtn = new ElaPushButton("删除选中", this); - ElaPushButton *saveBtn = new ElaPushButton("保存", this); - - connect(addBtn, &ElaPushButton::clicked, this, &FunctionMappingPage::onAddRow); - connect(delBtn, &ElaPushButton::clicked, this, &FunctionMappingPage::onDeleteRow); - connect(saveBtn, &ElaPushButton::clicked, this, &FunctionMappingPage::onSave); - - btnLayout->addWidget(addBtn); - btnLayout->addWidget(delBtn); - btnLayout->addStretch(); - btnLayout->addWidget(saveBtn); - mainLayout->addLayout(btnLayout); - - QHBoxLayout *tipLayout = new QHBoxLayout; - m_tipLabel = new QLabel("💡 双击表格修改数据"); - tipLayout->addWidget(m_tipLabel); - tipLayout->addStretch(); - mainLayout->addLayout(tipLayout); - - // 文件监控:检测 cust.json 外部修改后自动刷新 - m_fileWatcher = new QFileSystemWatcher(this); - connect(m_fileWatcher, &QFileSystemWatcher::fileChanged, this, &FunctionMappingPage::onFileChanged); - connect(m_fileWatcher, &QFileSystemWatcher::directoryChanged, this, &FunctionMappingPage::onDirChanged); - - // 外部变更防抖: 编辑器/生成器常多次小步写文件, 合并为一次处理, - // 弹窗/重载不再直接嵌套在 watcher 回调(嵌套事件循环可重入) - m_reloadTimer = new QTimer(this); - m_reloadTimer->setSingleShot(true); - m_reloadTimer->setInterval(300); - connect(m_reloadTimer, &QTimer::timeout, this, &FunctionMappingPage::onReloadTimeout); - - // tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化 - connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, - this, &FunctionMappingPage::updateTipStyle); - updateTipStyle(); -} - -void FunctionMappingPage::updateTipStyle() -{ - const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); - const QString textColor = ElaThemeColor(mode, BasicText).name(); - m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); -} - -void FunctionMappingPage::refreshWatchPaths() -{ - const QString jsonPath = getCustJsonPath(); - if (!jsonPath.isEmpty() && QFile::exists(jsonPath) - && !m_fileWatcher->files().contains(jsonPath)) { - m_fileWatcher->addPath(jsonPath); - } - const QString dir = QFileInfo(jsonPath).absolutePath(); - if (!dir.isEmpty() && QDir(dir).exists() - && !m_fileWatcher->directories().contains(dir)) { - m_fileWatcher->addPath(dir); - } -} - -QDateTime FunctionMappingPage::currentFileMtime() const -{ - QFileInfo info(getCustJsonPath()); - return info.exists() ? info.lastModified() : QDateTime(); -} - -void FunctionMappingPage::loadData() -{ - m_isLoading = true; - - bool readOk = false; - QJsonObject custJson = readCustJson(&readOk); - if (!readOk) { - // 读取/解析失败时保留现有模型内容并不清脏: 原实现先清空后读取, 失败即空表, - // 用户随手点"保存"就把空映射写回磁盘, 全部数据永久丢失 - m_isLoading = false; - LogManager::instance()->logWarning( - "cust.json 读取/解析失败, 保留当前表格内容; 请先恢复文件再刷新, 直接保存会以空数据覆盖"); - refreshWatchPaths(); - return; - } - - m_model->removeRows(0, m_model->rowCount()); - - if (custJson.contains("functionReplace")) { - QJsonObject dataObj = custJson["functionReplace"].toObject(); - QStringList keys = dataObj.keys(); - for (const QString &key : keys) { - QList rowItems; - rowItems.append(new QStandardItem(key)); - rowItems.append(new QStandardItem(dataObj[key].toString())); - m_model->appendRow(rowItems); - } - } - - m_isLoading = false; - m_dirty = false; // 加载完成后与磁盘内容一致,清除脏标志 - - // 设置文件+目录双监控并记录 mtime 基准, 供防抖回调过滤自身写入回环 - refreshWatchPaths(); - m_lastFileMtime = currentFileMtime(); -} - -QString FunctionMappingPage::getCustJsonPath() const -{ - return Constants::custJson(); -} - -QJsonObject FunctionMappingPage::readCustJson(bool *ok) -{ - if (ok) *ok = false; - QString jsonPath = getCustJsonPath(); - QFile file(jsonPath); - if (!file.open(QIODevice::ReadOnly)) { - LogManager::instance()->logError("无法读取 cust.json: " + jsonPath); - return QJsonObject(); - } - QByteArray data = file.readAll(); - file.close(); - QJsonParseError error; - QJsonDocument doc = QJsonDocument::fromJson(data, &error); - if (error.error != QJsonParseError::NoError) { - LogManager::instance()->logError( - QString("cust.json 解析失败: %1 (行 %2)").arg(error.errorString()).arg(error.offset)); - return QJsonObject(); - } - if (ok) *ok = true; - return doc.object(); -} - -// 从原始 JSON 文本中提取顶层 key 的顺序: -// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键。 -// 旧正则 "\w+" 无法匹配中文/特殊字符键, 且会误捕嵌套键, 导致保存时顶层字段丢失 -static QStringList extractTopLevelKeyOrder(const QString &rawJson) -{ - QStringList keys; - int depth = 0; - bool inString = false; - bool escaped = false; - int strStart = -1; - for (int i = 0; i < rawJson.length(); ++i) { - const QChar ch = rawJson[i]; - if (inString) { - if (escaped) { escaped = false; } - else if (ch == '\\') { escaped = true; } - else if (ch == '"') { - inString = false; - // 字符串闭合后跳过空白, 紧跟冒号且处于最外层对象内 → 顶层键 - int j = i + 1; - while (j < rawJson.length() && rawJson[j].isSpace()) ++j; - if (depth == 1 && strStart >= 0 && j < rawJson.length() - && rawJson[j] == ':') { - const QString key = rawJson.mid(strStart + 1, i - strStart - 1); - if (!key.isEmpty() && !keys.contains(key)) { - keys.append(key); - } - } - } - } else { - if (ch == '"') { inString = true; strStart = i; } - else if (ch == '{' || ch == '[') { ++depth; } - else if (ch == '}' || ch == ']') { --depth; } - } - } - return keys; -} - -// 对 JSON 字符串进行缩进处理 -static QString indentJsonValue(const QString &json) -{ - if (json.isEmpty()) return json; - QStringList lines = json.split('\n'); - QString result; - for (const QString &line : lines) { - if (!line.trimmed().isEmpty()) - result += " " + line + "\n"; - } - return result; -} - -// 写入 JSON 值,根据类型选择合适的格式 -static QString formatJsonValue(const QJsonValue &value) -{ - if (value.isObject()) { - QJsonDocument doc(value.toObject()); - QString raw = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); - return indentJsonValue(raw); - } else if (value.isArray()) { - QJsonDocument doc(value.toArray()); - QString raw = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); - return indentJsonValue(raw); - } else if (value.isString()) { - QString escaped = value.toString(); - escaped.replace("\\", "\\\\"); - escaped.replace("\"", "\\\""); - escaped.replace("\n", "\\n"); - escaped.replace("\r", "\\r"); - escaped.replace("\t", "\\t"); - return "\"" + escaped + "\""; - } else if (value.isBool()) { - return value.toBool() ? "true" : "false"; - } else if (value.isDouble()) { - double d = value.toDouble(); - if (d == static_cast(d)) - return QString::number(static_cast(d)); - return QString::number(d, 'f'); - } - return "null"; -} - -// 按顺序格式化一个 string->string 键值对的嵌套 JSON 对象 -static QString formatOrderedJsonObject(const QList> &pairs) -{ - QString result = "{\n"; - bool first = true; - for (const auto &pair : pairs) { - if (!first) result += ",\n"; - first = false; - QString ek = pair.first; - ek.replace("\\", "\\\\"); ek.replace("\"", "\\\""); - QString ev = pair.second; - ev.replace("\\", "\\\\"); ev.replace("\"", "\\\""); - ev.replace("\n", "\\n"); ev.replace("\r", "\\r"); ev.replace("\t", "\\t"); - result += " \"" + ek + "\": \"" + ev + "\""; - } - result += "\n }"; - return result; -} - -bool FunctionMappingPage::writeCustJson(const QJsonObject &json, const QStringList &keyOrder, - const QString &orderedKey, const QList> &orderedData) -{ - QString jsonPath = getCustJsonPath(); - // QSaveFile 原子写: cust.json 为全局共享配置(承载 functionReplace 与 convert.dir 等), - // 裸 Truncate 写中途异常会留下截断/损坏的 JSON, 与 tablemappingpage 保持一致 - QSaveFile file(jsonPath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - LogManager::instance()->logError("无法写入 cust.json: " + jsonPath); - return false; - } - - QTextStream out(&file); - out << "{\n"; - bool first = true; - for (const QString &key : keyOrder) { - if (!json.contains(key)) continue; - if (!first) out << ",\n"; - first = false; - out << " \"" << key << "\": "; - if (key == orderedKey) { - out << formatOrderedJsonObject(orderedData); - } else { - out << formatJsonValue(json[key]); - } - } - out << "\n}\n"; - out.flush(); - if (!file.commit()) { - LogManager::instance()->logError("cust.json 提交失败(磁盘空间/权限/占用?): " + jsonPath); - return false; - } - return true; -} - -void FunctionMappingPage::onAddRow() -{ - QDialog dialog(this); - dialog.setWindowTitle("添加函数映射"); - dialog.resize(450, 180); - - QVBoxLayout *layout = new QVBoxLayout(&dialog); - layout->setSpacing(12); - - QHBoxLayout *keyLayout = new QHBoxLayout; - ElaText *keyLabel = new ElaText("UF20:", &dialog); - keyLabel->setTextPointSize(10); - keyLayout->addWidget(keyLabel); - ElaLineEdit *keyEdit = new ElaLineEdit(&dialog); - keyEdit->setPlaceholderText("输入UF20函数名"); - keyLayout->addWidget(keyEdit); - layout->addLayout(keyLayout); - - QHBoxLayout *valLayout = new QHBoxLayout; - ElaText *valLabel = new ElaText("UFT3:", &dialog); - valLabel->setTextPointSize(10); - valLayout->addWidget(valLabel); - ElaLineEdit *valEdit = new ElaLineEdit(&dialog); - valEdit->setPlaceholderText("输入UFT3函数名"); - valLayout->addWidget(valEdit); - layout->addLayout(valLayout); - - QHBoxLayout *btnLayout = new QHBoxLayout; - ElaPushButton *okBtn = new ElaPushButton("确定", &dialog); - ElaPushButton *cancelBtn = new ElaPushButton("取消", &dialog); - btnLayout->addStretch(); - btnLayout->addWidget(okBtn); - btnLayout->addWidget(cancelBtn); - layout->addLayout(btnLayout); - - connect(okBtn, &ElaPushButton::clicked, &dialog, &QDialog::accept); - connect(cancelBtn, &ElaPushButton::clicked, &dialog, &QDialog::reject); - - if (dialog.exec() != QDialog::Accepted) return; - - QString key = keyEdit->text().trimmed(); - QString val = valEdit->text().trimmed(); - - if (key.isEmpty()) { - QMessageBox::warning(this, "提示", "UF20 函数名不能为空!"); - return; - } - - for (int row = 0; row < m_model->rowCount(); ++row) { - if (m_model->item(row, 0) && m_model->item(row, 0)->text() == key) { - QMessageBox::warning(this, "提示", QString("UF20 函数名 \"%1\" 已存在!").arg(key)); - return; - } - } - - QList rowItems; - rowItems.append(new QStandardItem(key)); - rowItems.append(new QStandardItem(val)); - m_model->appendRow(rowItems); -} - -void FunctionMappingPage::onDeleteRow() -{ - QModelIndexList selectedRows = m_tableView->selectionModel()->selectedRows(); - if (selectedRows.isEmpty()) { - QMessageBox::warning(this, "提示", "请先选中要删除的行!"); - return; - } - - QStringList keyNames; - QList sourceRows; - for (const QModelIndex &idx : selectedRows) { - QModelIndex srcIdx = m_proxyModel->mapToSource(idx); - QStandardItem *keyItem = m_model->item(srcIdx.row(), 0); - if (keyItem) keyNames.append(keyItem->text()); - sourceRows.append(srcIdx.row()); - } - - QString confirmMsg; - if (selectedRows.size() == 1) { - confirmMsg = QString("确定要删除 \"%1\" 这条映射吗?").arg(keyNames.first()); - } else { - confirmMsg = QString("确定要删除选中的 %1 条映射吗?").arg(selectedRows.size()); - } - - QMessageBox::StandardButton reply = QMessageBox::question(this, "确认删除", - confirmMsg, QMessageBox::Yes | QMessageBox::No); - if (reply != QMessageBox::Yes) return; - - // 从高行号到低行号删除,避免索引偏移问题 - std::sort(sourceRows.begin(), sourceRows.end(), std::greater()); - for (int row : sourceRows) { - m_model->removeRow(row); - } - m_dirty = true; // 删除行同样视为未保存修改,纳入外部文件变更保护 -} - -void FunctionMappingPage::onModelItemChanged(QStandardItem *item) -{ - if (m_isLoading) return; - m_dirty = true; // 用户编辑了映射表,标记有未保存修改(与 TableMappingPage 行为一致) - Q_UNUSED(item); -} - -void FunctionMappingPage::onSave() -{ - // 标记正在保存,忽略文件监控事件 - m_isSaving = true; - // 递增保存代数: 2s 延迟复位标记时比对代数, 窗口期内再次保存时旧计时器失效, - // 不会把新一轮 m_isSaving 提前复位(提前复位后外部事件误触发重载询问) - const quint64 seq = ++m_saveSeq; - - // 读取原始文件内容以保留顶层 key 顺序 - QString jsonPath = getCustJsonPath(); - QFile file(jsonPath); - if (!file.open(QIODevice::ReadOnly)) { - // 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制 - m_isSaving = false; - QMessageBox::critical(this, "错误", "无法读取 cust.json!"); - return; - } - QString rawJson = QString::fromUtf8(file.readAll()); - file.close(); - - QJsonParseError error; - QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error); - if (error.error != QJsonParseError::NoError) { - // 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制 - m_isSaving = false; - QMessageBox::critical(this, "错误", "cust.json 解析失败: " + error.errorString()); - return; - } - QJsonObject custJson = doc.object(); - - // 提取原始 key 顺序 - QStringList keyOrder = extractTopLevelKeyOrder(rawJson); - - // 构建有序的函数替换数据 - QJsonObject functionReplaceObj; - QList> funcData; - for (int row = 0; row < m_model->rowCount(); ++row) { - QStandardItem *keyItem = m_model->item(row, 0); - QStandardItem *valItem = m_model->item(row, 1); - if (keyItem && !keyItem->text().trimmed().isEmpty()) { - QString k = keyItem->text().trimmed(); - QString v = valItem ? valItem->text().trimmed() : ""; - functionReplaceObj[k] = v; - funcData.append({k, v}); - } - } - custJson["functionReplace"] = functionReplaceObj; - - if (writeCustJson(custJson, keyOrder, "functionReplace", funcData)) { - m_dirty = false; // 保存成功后与磁盘内容一致 - m_lastFileMtime = currentFileMtime(); // 更新 mtime 基准, 防抖回调据此过滤自身写入 - LogManager::instance()->logInfo("函数映射配置已保存到 cust.json"); - QMessageBox::information(this, "提示", "保存成功!"); - } else { - QMessageBox::critical(this, "错误", "保存到 cust.json 失败!"); - m_isSaving = false; - } - - // 延迟重置保存标记,让文件监控忽略本次写入事件(比对代数, 见 onSave 开头) - QTimer::singleShot(2000, this, [this, seq]() { - if (m_saveSeq != seq) return; // 窗口期内又有新保存, 本计时器失效 - m_isSaving = false; - // 重新添加监控路径 - refreshWatchPaths(); - }); -} - -void FunctionMappingPage::onFileChanged(const QString &path) -{ - // Windows 下文件被整文件替换后 watcher 会自动移除该路径,先补挂保证后续事件仍能收到 - if (m_fileWatcher && !m_fileWatcher->files().contains(path) && QFile::exists(path)) { - m_fileWatcher->addPath(path); - } - if (m_isSaving) return; // 自身写入触发的回环,忽略 - - m_reloadTimer->start(); // 防抖: 编辑器保存常触发多次事件,300ms 后统一处理 -} - -void FunctionMappingPage::onDirChanged(const QString &path) -{ - Q_UNUSED(path); - // 目录事件兜底文件被替换/删除重建的场景,重新补挂文件监控路径 - refreshWatchPaths(); - if (m_isSaving) return; - - m_reloadTimer->start(); -} - -void FunctionMappingPage::onReloadTimeout() -{ - m_reloadTimer->stop(); - - // mtime 比对过滤自身写入回环: mtime 未变说明是刚才自己保存的内容,无需重载。 - // <= 而非 ==: 覆盖时钟回拨与 FAT/exFAT 2s 粒度截断的场景(外部修改的 mtime - // 只会更新或持平基准, 不会变旧), NTFS 下两者等价 - const QDateTime now = currentFileMtime(); - if (!now.isValid() || now <= m_lastFileMtime) return; - - // 有未保存编辑时询问,避免外部修改静默覆盖用户的编辑 - if (m_dirty) { - QMessageBox::StandardButton reply = QMessageBox::question(this, "文件已变更", - "cust.json 已被其他页面或外部程序修改,重新加载将丢失当前未保存的编辑。是否重新加载?", - QMessageBox::Yes | QMessageBox::No); - if (reply != QMessageBox::Yes) { - // 记住本次版本,避免同一份内容反复弹窗询问 - m_lastFileMtime = now; - refreshWatchPaths(); - return; - } - } - - m_lastFileMtime = now; - LogManager::instance()->logInfo("检测到 cust.json 外部修改,自动刷新数据"); - loadData(); -} - -void FunctionMappingPage::onSearchTextChanged(const QString &text) -{ - static_cast(m_proxyModel)->setFilterText(text); + load(); } diff --git a/src/pages/datamapping/functionmappingpage.h b/src/pages/datamapping/functionmappingpage.h index 348b503..a17400d 100644 --- a/src/pages/datamapping/functionmappingpage.h +++ b/src/pages/datamapping/functionmappingpage.h @@ -1,61 +1,17 @@ #ifndef FUNCTIONMAPPINGPAGE_H #define FUNCTIONMAPPINGPAGE_H -#include -#include -#include -#include -#include -#include -#include -#include "ElaTableView.h" +#include "twocolumnkvmappingpage.h" -class ElaLineEdit; -class QLabel; -class QTimer; - -class FunctionMappingPage : public QWidget +// 维护 cust.json 顶层键 functionReplace(UF20→UFT3 函数替换映射): +// 两列映射表, 支持 新增(弹窗)/双击修改/删除/搜索过滤, 保存按顶层键顺序保序写回; +// 填充/校验/新增弹窗样板由 TwoColumnKVMappingPage 中间基类收编 +class FunctionMappingPage : public TwoColumnKVMappingPage { Q_OBJECT public: explicit FunctionMappingPage(QWidget *parent = nullptr); - ~FunctionMappingPage(); - -private slots: - void onAddRow(); - void onDeleteRow(); - void onModelItemChanged(QStandardItem *item); - void onSave(); - void onFileChanged(const QString &path); - void onDirChanged(const QString &path); - void onReloadTimeout(); - void onSearchTextChanged(const QString &text); - // 提示标签跟随主题明暗切换 - void updateTipStyle(); - -private: - void initUI(); - void loadData(); - QJsonObject readCustJson(bool *ok = nullptr); - bool writeCustJson(const QJsonObject &json, const QStringList &keyOrder, - const QString &orderedKey, const QList> &orderedData); - QString getCustJsonPath() const; - // 文件+目录双监控补挂(Windows 下整文件替换会自动移除文件监控路径, 目录事件兜底) - void refreshWatchPaths(); - QDateTime currentFileMtime() const; - - ElaTableView *m_tableView; - QStandardItemModel *m_model; - QSortFilterProxyModel *m_proxyModel; - QFileSystemWatcher *m_fileWatcher; - QTimer *m_reloadTimer = nullptr; // 外部变更防抖(300ms 合并多次小步写入) - QDateTime m_lastFileMtime; // loadData 时基准, onReloadTimeout 据此过滤自身写入回环 - QLabel *m_tipLabel = nullptr; - quint64 m_saveSeq = 0; // 保存代数: 2s 延迟复位标记时比对, 防窗口期内二次保存被旧计时器提前复位 - bool m_isLoading; - bool m_isSaving; - bool m_dirty = false; // 有未保存的编辑(onModelItemChanged 置脏,loadData/onSave 成功清脏) }; #endif // FUNCTIONMAPPINGPAGE_H diff --git a/src/pages/datamapping/mappingtablepage.cpp b/src/pages/datamapping/mappingtablepage.cpp new file mode 100644 index 0000000..19d4d4b --- /dev/null +++ b/src/pages/datamapping/mappingtablepage.cpp @@ -0,0 +1,315 @@ +#include "mappingtablepage.h" +#include "utils/Constants.h" +#include "src/utils/logmanager.h" +#include "src/utils/custjsonio.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ElaPushButton.h" +#include "ElaLineEdit.h" +#include "ElaTableView.h" +#include "ElaTheme.h" + +// 数据映射三页(表替换/函数替换/不转码功能函数)的公共基类: +// 收编 UI 骨架/删除/编辑置脏/保存保序写/外部修改重载/主题联动等同构样板; +// 数据结构与校验差异由子类通过 populateModel/collectMappingData/onAddRow 实现 +MappingTablePage::MappingTablePage(int columnCount, + const QStringList &headers, + const QString &searchPlaceholder, + const QString &tipText, + QWidget *parent) + : QWidget(parent) + , m_isLoading(false) + , m_dirty(false) +{ + initUI(columnCount, headers, searchPlaceholder, tipText); +} + +MappingTablePage::~MappingTablePage() +{ +} + +void MappingTablePage::initUI(int columnCount, const QStringList &headers, + const QString &searchPlaceholder, const QString &tipText) +{ + QVBoxLayout *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(20, 20, 20, 20); + mainLayout->setSpacing(10); + + // 搜索框 + QHBoxLayout *searchLayout = new QHBoxLayout; + m_searchEdit = new ElaLineEdit(this); + m_searchEdit->setPlaceholderText(searchPlaceholder); + m_searchEdit->setClearButtonEnabled(true); + connect(m_searchEdit, &ElaLineEdit::textChanged, + this, &MappingTablePage::onSearchTextChanged); + searchLayout->addWidget(m_searchEdit); + mainLayout->addLayout(searchLayout); + + m_model = new QStandardItemModel(this); + m_model->setColumnCount(columnCount); + m_model->setHorizontalHeaderLabels(headers); + connect(m_model, &QStandardItemModel::itemChanged, + this, &MappingTablePage::onModelItemChanged); + + m_proxyModel = new MappingFilterProxyModel(columnCount, this); + m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + m_proxyModel->setSourceModel(m_model); + + m_tableView = new ElaTableView; + m_tableView->setModel(m_proxyModel); + m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); + m_tableView->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_tableView->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); + m_tableView->verticalHeader()->setVisible(false); + for (int c = 0; c < columnCount; ++c) + m_tableView->horizontalHeader()->setSectionResizeMode(c, QHeaderView::Stretch); + mainLayout->addWidget(m_tableView); + + QHBoxLayout *btnLayout = new QHBoxLayout; + ElaPushButton *addBtn = new ElaPushButton("添加", this); + ElaPushButton *delBtn = new ElaPushButton("删除选中", this); + ElaPushButton *saveBtn = new ElaPushButton("保存", this); + + // 虚函数经 this 指针调用, 运行时正确分派到子类实现 + connect(addBtn, &ElaPushButton::clicked, this, &MappingTablePage::onAddRow); + connect(delBtn, &ElaPushButton::clicked, this, &MappingTablePage::onDeleteRow); + connect(saveBtn, &ElaPushButton::clicked, this, &MappingTablePage::onSave); + + btnLayout->addWidget(addBtn); + btnLayout->addWidget(delBtn); + btnLayout->addStretch(); + btnLayout->addWidget(saveBtn); + mainLayout->addLayout(btnLayout); + + QHBoxLayout *tipLayout = new QHBoxLayout; + m_tipLabel = new QLabel(tipText); + tipLayout->addWidget(m_tipLabel); + tipLayout->addStretch(); + mainLayout->addLayout(tipLayout); + + // 外部修改监控: 双监控+防抖+mtime 回环过滤+保存窗口统一由 JsonFileMonitor 处理 + m_monitor = new JsonFileMonitor(this); + connect(m_monitor, &JsonFileMonitor::reloadRequested, + this, &MappingTablePage::onReloadRequested); + + // tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化 + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &MappingTablePage::updateTipStyle); + updateTipStyle(); +} + +// 由子类构造体内调用(规避构造期虚函数绑定到基类版本): 先读后清空, +// 读取/解析失败时保留现有模型内容并不清脏, 避免用户随手保存把空表写回磁盘 +void MappingTablePage::load() +{ + m_isLoading = true; + + bool readOk = false; + QJsonObject custJson = readCustJson(&readOk).object(); + if (readOk) { + // 批量重建期间抑制逐行重绘: 映射表可达数百~上千行, 三个子页共用此路径, + // 基类统一加抑制子页同时受益(populateModel 为虚函数) + m_tableView->setUpdatesEnabled(false); + m_model->removeRows(0, m_model->rowCount()); + } + + if (readOk) { + populateModel(custJson); + m_tableView->setUpdatesEnabled(true); + } + + m_isLoading = false; + // 仅读取/解析成功时清脏: 失败时模型保留了旧内容(可能含未保存编辑), + // 清脏会让外部变更保护失效, 且"保存"会把旧表写回损坏的文件 + if (readOk) { + m_dirty = false; + } + + // 设置监控并记录 mtime 基准, 供防抖回调过滤自身写入回环 + m_monitor->watch(getCustJsonPath()); +} + +QString MappingTablePage::getCustJsonPath() const +{ + return Constants::custJson(); +} + +QJsonDocument MappingTablePage::readCustJson(bool *ok) +{ + if (ok) *ok = false; + const QString jsonPath = getCustJsonPath(); + QFile file(jsonPath); + if (!file.open(QIODevice::ReadOnly)) { + LogManager::instance()->logError("无法读取 cust.json: " + jsonPath); + return QJsonDocument(); + } + const QByteArray data = file.readAll(); + file.close(); + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + LogManager::instance()->logError( + QString("cust.json 解析失败: %1 (行 %2)").arg(error.errorString()).arg(error.offset)); + return QJsonDocument(); + } + if (ok) *ok = true; + return doc; +} + +void MappingTablePage::onDeleteRow() +{ + QModelIndexList selectedRows = m_tableView->selectionModel()->selectedRows(); + if (selectedRows.isEmpty()) { + QMessageBox::warning(this, "提示", "请先选中要删除的行!"); + return; + } + + QStringList keyNames; + QList sourceRows; + for (const QModelIndex &idx : selectedRows) { + QModelIndex srcIdx = m_proxyModel->mapToSource(idx); + QStandardItem *keyItem = m_model->item(srcIdx.row(), 0); + if (keyItem) keyNames.append(keyItem->text()); + sourceRows.append(srcIdx.row()); + } + + // 单选但键名 item 缺失(空单元格/排序中列被隐藏)时 keyNames 可能为空, + // 退化为按数量确认, 避免 QList::first() 对空列表断言崩溃 + const QString confirmMsg = (selectedRows.size() == 1 && !keyNames.isEmpty()) + ? deleteConfirmSingle(keyNames.first()) + : deleteConfirmMulti(selectedRows.size()); + + QMessageBox::StandardButton reply = QMessageBox::question(this, "确认删除", + confirmMsg, QMessageBox::Yes | QMessageBox::No); + if (reply != QMessageBox::Yes) return; + + // 从高行号到低行号删除,避免索引偏移问题 + std::sort(sourceRows.begin(), sourceRows.end(), std::greater()); + for (int row : sourceRows) { + m_model->removeRow(row); + } + m_dirty = true; // 删除行同样视为未保存修改,纳入外部文件变更保护 +} + +void MappingTablePage::onModelItemChanged(QStandardItem *item) +{ + if (m_isLoading) return; + m_dirty = true; // 用户编辑了映射表,标记有未保存修改 + Q_UNUSED(item); +} + +void MappingTablePage::onSave() +{ + // 开启保存窗口: 窗口期内监控事件直接忽略 + m_monitor->beginSaveWindow(); + + // 读取原始文件内容以保留顶层 key 顺序 + const QString jsonPath = getCustJsonPath(); + QFile file(jsonPath); + if (!file.open(QIODevice::ReadOnly)) { + m_monitor->endSave(); // 早退必须关闭窗口,否则监控被永久抑制 + QMessageBox::critical(this, "错误", "无法读取 cust.json!"); + return; + } + const QString rawJson = QString::fromUtf8(file.readAll()); + file.close(); + + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error); + if (error.error != QJsonParseError::NoError) { + m_monitor->endSave(); + QMessageBox::critical(this, "错误", "cust.json 解析失败: " + error.errorString()); + return; + } + QJsonObject custJson = doc.object(); + + // 提取原始 key 顺序 + const QStringList keyOrder = CustJsonIo::extractTopLevelKeyOrder(rawJson); + + // 子类校验并回填数据: 校验失败置 errorMessage, 弹警告不落盘 + QString errorMessage; + const QHash overrideRaw = collectMappingData(custJson, errorMessage); + if (!errorMessage.isEmpty()) { + m_monitor->endSave(); + QMessageBox::warning(this, "提示", errorMessage); + return; + } + + // endSave 以保存后的新 mtime 为基准(writeCustJsonOrdered 同步写完才返回), + // 成功/失败都要关闭窗口, 且必须先于模态弹窗: + // 弹窗阻塞期间若发生外部修改, 迟到的 endSave 会把外部修改的 mtime + // 当作自身保存基准, 导致外部变更被静默吞掉 + const bool writeOk = CustJsonIo::writeCustJsonOrdered(jsonPath, custJson, keyOrder, overrideRaw); + m_monitor->endSave(); + if (writeOk) { + m_dirty = false; // 保存成功后与磁盘内容一致 + LogManager::instance()->logInfo(saveLogText(m_model->rowCount())); + QMessageBox::information(this, "提示", "保存成功!"); + } else { + QMessageBox::critical(this, "错误", "保存到 cust.json 失败!"); + } +} + +void MappingTablePage::onReloadRequested() +{ + // 有未保存编辑时询问,避免外部修改静默覆盖用户的编辑 + if (m_dirty) { + QMessageBox::StandardButton reply = QMessageBox::question(this, "文件已变更", + "cust.json 已被其他页面或外部程序修改,重新加载将丢失当前未保存的编辑。是否重新加载?", + QMessageBox::Yes | QMessageBox::No); + if (reply != QMessageBox::Yes) { + return; // 监控组件已记住本次版本,避免同一份内容反复弹窗询问 + } + } + + LogManager::instance()->logInfo(reloadLogText()); + load(); +} + +void MappingTablePage::onSearchTextChanged(const QString &text) +{ + // setFilterFixedString 自动转义正则元字符, 按不区分大小写的包含匹配 + m_proxyModel->setFilterFixedString(text); +} + +void MappingTablePage::updateTipStyle() +{ + if (!m_tipLabel) return; + const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); + const QString textColor = ElaThemeColor(mode, BasicText).name(); + m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); +} + +// --------------------------------------------------------------------------- +// 默认文案虚点实现(子类按需覆写) +// --------------------------------------------------------------------------- +QString MappingTablePage::saveLogText(int rowCount) const +{ + Q_UNUSED(rowCount); + return "配置已保存到 cust.json"; +} + +QString MappingTablePage::reloadLogText() const +{ + return "检测到 cust.json 外部修改,自动刷新数据"; +} + +QString MappingTablePage::deleteConfirmSingle(const QString &name) const +{ + return QString("确定要删除 \"%1\" 这条映射吗?").arg(name); +} + +QString MappingTablePage::deleteConfirmMulti(int count) const +{ + return QString("确定要删除选中的 %1 条映射吗?").arg(count); +} diff --git a/src/pages/datamapping/mappingtablepage.h b/src/pages/datamapping/mappingtablepage.h new file mode 100644 index 0000000..06fdf56 --- /dev/null +++ b/src/pages/datamapping/mappingtablepage.h @@ -0,0 +1,117 @@ +#ifndef MAPPINGTABLEPAGE_H +#define MAPPINGTABLEPAGE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "utils/jsonfilemonitor.h" + +class ElaLineEdit; +class ElaTableView; +class ElaPushButton; +class QLabel; + +// --------------------------------------------------------------------------- +// MappingFilterProxyModel:参数化列数的 contains 过滤代理(不区分大小写) +// --------------------------------------------------------------------------- +class MappingFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + explicit MappingFilterProxyModel(int columnCount, QObject *parent = nullptr) + : QSortFilterProxyModel(parent), m_columnCount(columnCount) {} + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override + { + const QRegularExpression rx = filterRegularExpression(); + if (rx.pattern().isEmpty()) + return true; + for (int c = 0; c < m_columnCount; ++c) { + const QModelIndex idx = sourceModel()->index(sourceRow, c, sourceParent); + if (rx.match(idx.data().toString()).hasMatch()) + return true; + } + return false; + } + +private: + int m_columnCount; +}; + +// --------------------------------------------------------------------------- +// MappingTablePage:数据映射三页(表替换/函数替换/不转码功能函数)的公共基类 +// +// 收编 UI 骨架:搜索框 + QStandardItemModel + 过滤代理 + ElaTableView +// + 添加/删除选中/保存按钮 + tip 标签 + JsonFileMonitor + 主题联动 +// +// 差异化虚点: +// populateModel —— 从 cust.json 填充模型(load() 内调用) +// collectMappingData —— 保存时校验并回填数据到 custJson(onSave 内调用) +// onAddRow —— 添加一行(各页数据结构不同) +// 可覆写文案:saveLogText / reloadLogText / +// deleteConfirmSingle / deleteConfirmMulti +// --------------------------------------------------------------------------- +class MappingTablePage : public QWidget +{ + Q_OBJECT +public: + explicit MappingTablePage(int columnCount, + const QStringList &headers, + const QString &searchPlaceholder, + const QString &tipText, + QWidget *parent = nullptr); + ~MappingTablePage() override; + +protected: + // 子类构造体内调用(规避构造期虚函数绑定到基类版本) + void load(); + + // 虚点:从 custJson 填充模型 + virtual void populateModel(const QJsonObject &custJson) = 0; + // 虚点:保存时校验并回填数据到 custJson;返回 overrideRaw(键名→格式化原文); + // 校验失败置 errorMessage(弹警告不落盘) + virtual QHash collectMappingData(QJsonObject &custJson, + QString &errorMessage) = 0; + // 虚点:添加一行 + virtual void onAddRow() = 0; + + // 文案虚点(可覆写) + virtual QString saveLogText(int rowCount) const; // 保存成功日志 + virtual QString reloadLogText() const; // 外部修改重载日志 + virtual QString deleteConfirmSingle(const QString &name) const; // 删除单条确认 + virtual QString deleteConfirmMulti(int count) const; // 删除多条确认 + +protected: + QStandardItemModel *m_model; + MappingFilterProxyModel *m_proxyModel; + ElaTableView *m_tableView; + ElaLineEdit *m_searchEdit; + QLabel *m_tipLabel; + JsonFileMonitor *m_monitor; + bool m_isLoading; + bool m_dirty; + +private slots: + void onDeleteRow(); + void onModelItemChanged(QStandardItem *item); + void onSave(); + void onSearchTextChanged(const QString &text); + void updateTipStyle(); + void onReloadRequested(); + +private: + QString getCustJsonPath() const; + QJsonDocument readCustJson(bool *ok); + + void initUI(int columnCount, const QStringList &headers, + const QString &searchPlaceholder, const QString &tipText); +}; + +#endif // MAPPINGTABLEPAGE_H diff --git a/src/pages/datamapping/nochangefullnamepage.cpp b/src/pages/datamapping/nochangefullnamepage.cpp new file mode 100644 index 0000000..50212c2 --- /dev/null +++ b/src/pages/datamapping/nochangefullnamepage.cpp @@ -0,0 +1,137 @@ +#include "nochangefullnamepage.h" +#include "utils/Constants.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "src/utils/custjsonio.h" +#include "ElaPushButton.h" +#include "ElaLineEdit.h" +#include "ElaText.h" + +NoChangeFullNamePage::NoChangeFullNamePage(QWidget *parent) + : MappingTablePage(1, {"功能全名"}, + "搜索功能全名...", "💡 双击表格修改条目;转码时这些功能全名不做替换", parent) +{ + load(); +} + +void NoChangeFullNamePage::populateModel(const QJsonObject &custJson) +{ + if (!custJson.contains(Constants::CustKeys::NoChangeFullName)) + return; + const QJsonArray arr = custJson[Constants::CustKeys::NoChangeFullName].toArray(); + for (const QJsonValue &v : arr) { + m_model->appendRow(new QStandardItem(v.toString())); + } +} + +QHash NoChangeFullNamePage::collectMappingData(QJsonObject &custJson, + QString &errorMessage) +{ + // 从表格收集条目; 重复校验: 就地编辑可能改成已有条目(JSON 数组不去重但语义上应为集合), + // onAddRow 只覆盖"新增"入口, 编辑入口必须在此兜底 + QSet seen; + QStringList items; + for (int row = 0; row < m_model->rowCount(); ++row) { + QStandardItem *item = m_model->item(row, 0); + // 空条目不再静默丢弃: 与 TwoColumnKVMappingPage 统一为报错提示, + // 静默跳过等于无提示丢数据, 用户无从得知哪行没存上 + if (!item || item->text().trimmed().isEmpty()) { + errorMessage = QString("第 %1 行的条目为空,请填写或删除该行后再保存!") + .arg(row + 1); + return {}; + } + const QString name = item->text().trimmed(); + if (seen.contains(name)) { + errorMessage = QString("第 %1 行的条目 \"%2\" 与其他行重复,请修改后再保存!") + .arg(row + 1).arg(name); + return {}; + } + seen.insert(name); + items.append(name); + } + + QJsonArray arr; + for (const QString &name : items) { + arr.append(name); + } + custJson[Constants::CustKeys::NoChangeFullName] = arr; + + return {{Constants::CustKeys::NoChangeFullName, + CustJsonIo::formatOrderedJsonArray(items)}}; +} + +void NoChangeFullNamePage::onAddRow() +{ + QDialog dialog(this); + dialog.setWindowTitle("添加不转码功能函数"); + dialog.resize(520, 160); + + QVBoxLayout *layout = new QVBoxLayout(&dialog); + layout->setSpacing(12); + + QHBoxLayout *nameLayout = new QHBoxLayout; + ElaText *nameLabel = new ElaText("功能全名:", &dialog); + nameLabel->setTextPointSize(10); + nameLayout->addWidget(nameLabel); + ElaLineEdit *nameEdit = new ElaLineEdit(&dialog); + nameEdit->setPlaceholderText("如: AF_用户公用_用户可操作分支获取"); + nameLayout->addWidget(nameEdit); + layout->addLayout(nameLayout); + + QHBoxLayout *btnLayout = new QHBoxLayout; + ElaPushButton *okBtn = new ElaPushButton("确定", &dialog); + ElaPushButton *cancelBtn = new ElaPushButton("取消", &dialog); + btnLayout->addStretch(); + btnLayout->addWidget(okBtn); + btnLayout->addWidget(cancelBtn); + layout->addLayout(btnLayout); + + connect(okBtn, &ElaPushButton::clicked, &dialog, &QDialog::accept); + connect(cancelBtn, &ElaPushButton::clicked, &dialog, &QDialog::reject); + + if (dialog.exec() != QDialog::Accepted) return; + + const QString name = nameEdit->text().trimmed(); + if (name.isEmpty()) { + QMessageBox::warning(this, "提示", "功能全名不能为空!"); + return; + } + + for (int row = 0; row < m_model->rowCount(); ++row) { + if (m_model->item(row, 0) && m_model->item(row, 0)->text().trimmed() == name) { + QMessageBox::warning(this, "提示", QString("\"%1\" 已存在!").arg(name)); + return; + } + } + + m_model->appendRow(new QStandardItem(name)); + m_dirty = true; // 新增行尚未保存 +} + +QString NoChangeFullNamePage::saveLogText(int rowCount) const +{ + return QString("不转码功能函数清单已保存到 cust.json(%1 条)").arg(rowCount); +} + +QString NoChangeFullNamePage::reloadLogText() const +{ + return "检测到 cust.json 外部修改,不转码功能函数清单自动刷新"; +} + +QString NoChangeFullNamePage::deleteConfirmSingle(const QString &name) const +{ + return QString("确定要删除 \"%1\" 吗?").arg(name); +} + +QString NoChangeFullNamePage::deleteConfirmMulti(int count) const +{ + return QString("确定要删除选中的 %1 条吗?").arg(count); +} diff --git a/src/pages/datamapping/nochangefullnamepage.h b/src/pages/datamapping/nochangefullnamepage.h new file mode 100644 index 0000000..2228f4b --- /dev/null +++ b/src/pages/datamapping/nochangefullnamepage.h @@ -0,0 +1,28 @@ +#ifndef NOCHANGEFULLNAMEPAGE_H +#define NOCHANGEFULLNAMEPAGE_H + +#include "mappingtablepage.h" + +// 维护 cust.json 顶层键 noChangeFullNameCode(字符串数组): 转码时跳过这些功能函数的替换; +// 单列清单, 支持 新增(弹窗)/双击修改/删除/搜索过滤, 保存按顶层键顺序保序写回; +// UI 骨架/删除/保存/外部修改重载等样板由 MappingTablePage 基类收编 +class NoChangeFullNamePage : public MappingTablePage +{ + Q_OBJECT + +public: + explicit NoChangeFullNamePage(QWidget *parent = nullptr); + +protected: + void populateModel(const QJsonObject &custJson) override; + QHash collectMappingData(QJsonObject &custJson, + QString &errorMessage) override; + void onAddRow() override; + + QString saveLogText(int rowCount) const override; + QString reloadLogText() const override; + QString deleteConfirmSingle(const QString &name) const override; + QString deleteConfirmMulti(int count) const override; +}; + +#endif // NOCHANGEFULLNAMEPAGE_H diff --git a/src/pages/datamapping/tablemappingpage.cpp b/src/pages/datamapping/tablemappingpage.cpp index 3a3e397..580bf66 100644 --- a/src/pages/datamapping/tablemappingpage.cpp +++ b/src/pages/datamapping/tablemappingpage.cpp @@ -1,582 +1,9 @@ #include "tablemappingpage.h" #include "utils/Constants.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "src/utils/logmanager.h" -#include "ElaPushButton.h" -#include "ElaLineEdit.h" -#include "ElaText.h" -#include "ElaTheme.h" - -// 自定义代理模型:支持两列同时过滤 -class TableFilterProxyModel : public QSortFilterProxyModel -{ -public: - explicit TableFilterProxyModel(QObject *parent = nullptr) : QSortFilterProxyModel(parent) {} - - void setFilterText(const QString &text) - { - m_filterText = text; - invalidateFilter(); - } - -protected: - bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override - { - if (m_filterText.isEmpty()) return true; - QString t0 = sourceModel()->data(sourceModel()->index(sourceRow, 0, sourceParent)).toString(); - QString t1 = sourceModel()->data(sourceModel()->index(sourceRow, 1, sourceParent)).toString(); - return t0.contains(m_filterText, Qt::CaseInsensitive) || t1.contains(m_filterText, Qt::CaseInsensitive); - } - -private: - QString m_filterText; -}; TableMappingPage::TableMappingPage(QWidget *parent) - : QWidget(parent) - , m_isLoading(false) - , m_isSaving(false) + : TwoColumnKVMappingPage(Constants::CustKeys::TableCompare, + "表", "表名", parent) { - initUI(); - loadData(); -} - -TableMappingPage::~TableMappingPage() -{ -} - -void TableMappingPage::initUI() -{ - QVBoxLayout *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(20, 20, 20, 20); - mainLayout->setSpacing(10); - - // 搜索框 - QHBoxLayout *searchLayout = new QHBoxLayout; - ElaLineEdit *searchEdit = new ElaLineEdit(this); - searchEdit->setPlaceholderText("搜索 UF20 或 UFT3 表名..."); - searchEdit->setClearButtonEnabled(true); - connect(searchEdit, &ElaLineEdit::textChanged, this, &TableMappingPage::onSearchTextChanged); - searchLayout->addWidget(searchEdit); - mainLayout->addLayout(searchLayout); - - m_model = new QStandardItemModel(this); - m_model->setColumnCount(2); - m_model->setHorizontalHeaderLabels({"UF20", "UFT3"}); - connect(m_model, &QStandardItemModel::itemChanged, this, &TableMappingPage::onModelItemChanged); - - m_proxyModel = new TableFilterProxyModel(this); - m_proxyModel->setSourceModel(m_model); - - m_tableView = new ElaTableView; - m_tableView->setModel(m_proxyModel); - m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); - m_tableView->setSelectionMode(QAbstractItemView::ExtendedSelection); - m_tableView->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - m_tableView->verticalHeader()->setVisible(false); - m_tableView->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); - m_tableView->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); - mainLayout->addWidget(m_tableView); - - QHBoxLayout *btnLayout = new QHBoxLayout; - ElaPushButton *addBtn = new ElaPushButton("添加", this); - ElaPushButton *delBtn = new ElaPushButton("删除选中", this); - ElaPushButton *saveBtn = new ElaPushButton("保存", this); - - connect(addBtn, &ElaPushButton::clicked, this, &TableMappingPage::onAddRow); - connect(delBtn, &ElaPushButton::clicked, this, &TableMappingPage::onDeleteRow); - connect(saveBtn, &ElaPushButton::clicked, this, &TableMappingPage::onSave); - - btnLayout->addWidget(addBtn); - btnLayout->addWidget(delBtn); - btnLayout->addStretch(); - btnLayout->addWidget(saveBtn); - mainLayout->addLayout(btnLayout); - - QHBoxLayout *tipLayout = new QHBoxLayout; - m_tipLabel = new QLabel("💡 双击表格修改数据"); - m_tipLabel->setStyleSheet("color: #666; font-size: 13px;"); - tipLayout->addWidget(m_tipLabel); - tipLayout->addStretch(); - mainLayout->addLayout(tipLayout); - - // 文件监控:检测 cust.json 外部修改后自动刷新(文件+目录双监控: - // Windows 下文件被外部程序替换/重建后原文件监控句柄会失效, 目录监控负责补挂) - m_fileWatcher = new QFileSystemWatcher(this); - connect(m_fileWatcher, &QFileSystemWatcher::fileChanged, this, &TableMappingPage::onFileChanged); - connect(m_fileWatcher, &QFileSystemWatcher::directoryChanged, this, &TableMappingPage::onDirChanged); - - // 外部变更防抖: 编辑器/生成器常多次小步写文件, 合并为一次处理, - // 弹窗/重载不再直接嵌套在 watcher 回调(嵌套事件循环可重入) - m_reloadTimer = new QTimer(this); - m_reloadTimer->setSingleShot(true); - m_reloadTimer->setInterval(300); - connect(m_reloadTimer, &QTimer::timeout, this, &TableMappingPage::onReloadTimeout); - - // tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化 - connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, - this, &TableMappingPage::updateTipStyle); - updateTipStyle(); -} - -void TableMappingPage::loadData() -{ - m_isLoading = true; - m_model->removeRows(0, m_model->rowCount()); - - QJsonObject custJson = readCustJson(); - if (custJson.contains("tableCompareUf2_Uf3")) { - QJsonObject dataObj = custJson["tableCompareUf2_Uf3"].toObject(); - QStringList keys = dataObj.keys(); - for (const QString &key : keys) { - QList rowItems; - rowItems.append(new QStandardItem(key)); - rowItems.append(new QStandardItem(dataObj[key].toString())); - m_model->appendRow(rowItems); - } - } - - m_isLoading = false; - m_dirty = false; // 加载完成后与磁盘内容一致,清除脏标志 - - // 设置文件监控(文件+目录双监控)并记录 mtime 基准, 供防抖回调过滤自身写入回环 - refreshWatchPaths(); - m_lastFileMtime = currentFileMtime(); -} - -void TableMappingPage::refreshWatchPaths() -{ - const QString jsonPath = getCustJsonPath(); - if (!jsonPath.isEmpty() && QFile::exists(jsonPath) - && !m_fileWatcher->files().contains(jsonPath)) { - m_fileWatcher->addPath(jsonPath); - } - const QString dir = QFileInfo(jsonPath).absolutePath(); - if (!dir.isEmpty() && QDir(dir).exists() - && !m_fileWatcher->directories().contains(dir)) { - m_fileWatcher->addPath(dir); - } -} - -QDateTime TableMappingPage::currentFileMtime() const -{ - QFileInfo info(getCustJsonPath()); - return info.exists() ? info.lastModified() : QDateTime(); -} - -QString TableMappingPage::getCustJsonPath() const -{ - return Constants::custJson(); -} - -QJsonObject TableMappingPage::readCustJson() -{ - QString jsonPath = getCustJsonPath(); - QFile file(jsonPath); - if (!file.open(QIODevice::ReadOnly)) { - LogManager::instance()->logError("无法读取 cust.json: " + jsonPath); - return QJsonObject(); - } - QByteArray data = file.readAll(); - file.close(); - QJsonParseError error; - QJsonDocument doc = QJsonDocument::fromJson(data, &error); - if (error.error != QJsonParseError::NoError) { - LogManager::instance()->logError("cust.json 解析失败: " + error.errorString()); - return QJsonObject(); - } - return doc.object(); -} - -// 从原始 JSON 文本中提取顶层 key 的顺序: -// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键。 -// 旧正则 "\w+" 无法匹配中文/特殊字符键, 且会误捕嵌套键, 导致保存时顶层字段丢失 -static QStringList extractTopLevelKeyOrder(const QString &rawJson) -{ - QStringList keys; - int depth = 0; - bool inString = false; - bool escaped = false; - int strStart = -1; - for (int i = 0; i < rawJson.length(); ++i) { - const QChar ch = rawJson[i]; - if (inString) { - if (escaped) { escaped = false; } - else if (ch == '\\') { escaped = true; } - else if (ch == '"') { - inString = false; - // 字符串闭合后跳过空白, 紧跟冒号且处于最外层对象内 → 顶层键 - int j = i + 1; - while (j < rawJson.length() && rawJson[j].isSpace()) ++j; - if (depth == 1 && strStart >= 0 && j < rawJson.length() - && rawJson[j] == ':') { - const QString key = rawJson.mid(strStart + 1, i - strStart - 1); - if (!key.isEmpty() && !keys.contains(key)) { - keys.append(key); - } - } - } - } else { - if (ch == '"') { inString = true; strStart = i; } - else if (ch == '{' || ch == '[') { ++depth; } - else if (ch == '}' || ch == ']') { --depth; } - } - } - return keys; -} - -// 对 JSON 字符串进行缩进处理 -static QString indentJsonValue(const QString &json) -{ - if (json.isEmpty()) return json; - QStringList lines = json.split('\n'); - QString result; - for (const QString &line : lines) { - if (!line.trimmed().isEmpty()) - result += " " + line + "\n"; - } - return result; -} - -// 写入 JSON 值,根据类型选择合适的格式 -static QString formatJsonValue(const QJsonValue &value) -{ - if (value.isObject()) { - QJsonDocument doc(value.toObject()); - QString raw = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); - return indentJsonValue(raw); - } else if (value.isArray()) { - QJsonDocument doc(value.toArray()); - QString raw = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); - return indentJsonValue(raw); - } else if (value.isString()) { - QString escaped = value.toString(); - escaped.replace("\\", "\\\\"); - escaped.replace("\"", "\\\""); - escaped.replace("\n", "\\n"); - escaped.replace("\r", "\\r"); - escaped.replace("\t", "\\t"); - return "\"" + escaped + "\""; - } else if (value.isBool()) { - return value.toBool() ? "true" : "false"; - } else if (value.isDouble()) { - double d = value.toDouble(); - if (d == static_cast(d)) - return QString::number(static_cast(d)); - return QString::number(d, 'f'); - } - return "null"; -} - -// 按顺序格式化一个 string->string 键值对的嵌套 JSON 对象 -static QString formatOrderedJsonObject(const QList> &pairs) -{ - QString result = "{\n"; - bool first = true; - for (const auto &pair : pairs) { - if (!first) result += ",\n"; - first = false; - QString ek = pair.first; - ek.replace("\\", "\\\\"); ek.replace("\"", "\\\""); - QString ev = pair.second; - ev.replace("\\", "\\\\"); ev.replace("\"", "\\\""); - ev.replace("\n", "\\n"); ev.replace("\r", "\\r"); ev.replace("\t", "\\t"); - result += " \"" + ek + "\": \"" + ev + "\""; - } - result += "\n }"; - return result; -} - -bool TableMappingPage::writeCustJson(const QJsonObject &json, const QStringList &keyOrder, - const QString &orderedKey, const QList> &orderedData) -{ - QString jsonPath = getCustJsonPath(); - // QSaveFile 原子写: 先写临时文件成功后再 commit 替换目标, - // 避免写一半崩溃/断电留下截断损坏的 cust.json(QFile+Truncate 会先清空原文件) - QSaveFile file(jsonPath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - LogManager::instance()->logError("无法写入 cust.json: " + jsonPath); - return false; - } - - QTextStream out(&file); - out << "{\n"; - bool first = true; - for (const QString &key : keyOrder) { - if (!json.contains(key)) continue; - if (!first) out << ",\n"; - first = false; - out << " \"" << key << "\": "; - if (key == orderedKey) { - out << formatOrderedJsonObject(orderedData); - } else { - out << formatJsonValue(json[key]); - } - } - out << "\n}\n"; - out.flush(); - if (!file.commit()) { - LogManager::instance()->logError( - QString("cust.json 提交失败(磁盘空间/权限/占用?): %1").arg(jsonPath)); - return false; - } - return true; -} - -void TableMappingPage::onAddRow() -{ - QDialog dialog(this); - dialog.setWindowTitle("添加表映射"); - dialog.resize(450, 180); - - QVBoxLayout *layout = new QVBoxLayout(&dialog); - layout->setSpacing(12); - - QHBoxLayout *keyLayout = new QHBoxLayout; - ElaText *keyLabel = new ElaText("UF20:", &dialog); - keyLabel->setTextPointSize(10); - keyLayout->addWidget(keyLabel); - ElaLineEdit *keyEdit = new ElaLineEdit(&dialog); - keyEdit->setPlaceholderText("输入UF20表名"); - keyLayout->addWidget(keyEdit); - layout->addLayout(keyLayout); - - QHBoxLayout *valLayout = new QHBoxLayout; - ElaText *valLabel = new ElaText("UFT3:", &dialog); - valLabel->setTextPointSize(10); - valLayout->addWidget(valLabel); - ElaLineEdit *valEdit = new ElaLineEdit(&dialog); - valEdit->setPlaceholderText("输入UFT3表名"); - valLayout->addWidget(valEdit); - layout->addLayout(valLayout); - - QHBoxLayout *btnLayout = new QHBoxLayout; - ElaPushButton *okBtn = new ElaPushButton("确定", &dialog); - ElaPushButton *cancelBtn = new ElaPushButton("取消", &dialog); - btnLayout->addStretch(); - btnLayout->addWidget(okBtn); - btnLayout->addWidget(cancelBtn); - layout->addLayout(btnLayout); - - connect(okBtn, &ElaPushButton::clicked, &dialog, &QDialog::accept); - connect(cancelBtn, &ElaPushButton::clicked, &dialog, &QDialog::reject); - - if (dialog.exec() != QDialog::Accepted) return; - - QString key = keyEdit->text().trimmed(); - QString val = valEdit->text().trimmed(); - - if (key.isEmpty()) { - QMessageBox::warning(this, "提示", "UF20 表名不能为空!"); - return; - } - - for (int row = 0; row < m_model->rowCount(); ++row) { - if (m_model->item(row, 0) && m_model->item(row, 0)->text() == key) { - QMessageBox::warning(this, "提示", QString("UF20 表名 \"%1\" 已存在!").arg(key)); - return; - } - } - - QList rowItems; - rowItems.append(new QStandardItem(key)); - rowItems.append(new QStandardItem(val)); - m_model->appendRow(rowItems); -} - -void TableMappingPage::onDeleteRow() -{ - QModelIndexList selectedRows = m_tableView->selectionModel()->selectedRows(); - if (selectedRows.isEmpty()) { - QMessageBox::warning(this, "提示", "请先选中要删除的行!"); - return; - } - - QStringList keyNames; - QList sourceRows; - for (const QModelIndex &idx : selectedRows) { - QModelIndex srcIdx = m_proxyModel->mapToSource(idx); - QStandardItem *keyItem = m_model->item(srcIdx.row(), 0); - if (keyItem) keyNames.append(keyItem->text()); - sourceRows.append(srcIdx.row()); - } - - QString confirmMsg; - if (selectedRows.size() == 1) { - confirmMsg = QString("确定要删除 \"%1\" 这条映射吗?").arg(keyNames.first()); - } else { - confirmMsg = QString("确定要删除选中的 %1 条映射吗?").arg(selectedRows.size()); - } - - QMessageBox::StandardButton reply = QMessageBox::question(this, "确认删除", - confirmMsg, QMessageBox::Yes | QMessageBox::No); - if (reply != QMessageBox::Yes) return; - - // 从高行号到低行号删除,避免索引偏移问题 - std::sort(sourceRows.begin(), sourceRows.end(), std::greater()); - for (int row : sourceRows) { - m_model->removeRow(row); - } - m_dirty = true; // 删除行同样视为未保存修改,纳入外部文件变更保护 -} - -void TableMappingPage::onModelItemChanged(QStandardItem *item) -{ - if (m_isLoading) return; - m_dirty = true; // 用户编辑了映射表,标记有未保存修改 - Q_UNUSED(item); -} - -void TableMappingPage::onSave() -{ - // 标记正在保存,忽略文件监控事件 - m_isSaving = true; - - // 读取原始文件内容以保留顶层 key 顺序 - QString jsonPath = getCustJsonPath(); - QFile file(jsonPath); - if (!file.open(QIODevice::ReadOnly)) { - // 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制 - m_isSaving = false; - QMessageBox::critical(this, "错误", "无法读取 cust.json!"); - return; - } - QString rawJson = QString::fromUtf8(file.readAll()); - file.close(); - - QJsonParseError error; - QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error); - if (error.error != QJsonParseError::NoError) { - // 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制 - m_isSaving = false; - QMessageBox::critical(this, "错误", "cust.json 解析失败: " + error.errorString()); - return; - } - QJsonObject custJson = doc.object(); - - // 提取原始 key 顺序 - QStringList keyOrder = extractTopLevelKeyOrder(rawJson); - - // 构建有序的表替换数据。 - // 重复 key 校验: 就地编辑第一列可能改成已有表名(JSON 对象同键去重会静默丢失其中一行), - // onAddRow 只覆盖"新增"入口, 编辑入口必须在此兜底 - QJsonObject tableCompareObj; - QList> tableData; - for (int row = 0; row < m_model->rowCount(); ++row) { - QStandardItem *keyItem = m_model->item(row, 0); - QStandardItem *valItem = m_model->item(row, 1); - if (keyItem && !keyItem->text().trimmed().isEmpty()) { - QString k = keyItem->text().trimmed(); - if (tableCompareObj.contains(k)) { - m_isSaving = false; - QMessageBox::warning(this, "提示", - QString("第 %1 行的 UF20 表名 \"%2\" 与其他行重复,请修改后再保存!") - .arg(row + 1).arg(k)); - return; - } - QString v = valItem ? valItem->text().trimmed() : ""; - tableCompareObj[k] = v; - tableData.append({k, v}); - } - } - custJson["tableCompareUf2_Uf3"] = tableCompareObj; - - if (writeCustJson(custJson, keyOrder, "tableCompareUf2_Uf3", tableData)) { - m_dirty = false; // 保存成功后与磁盘内容一致 - m_lastFileMtime = currentFileMtime(); // 更新 mtime 基准, 防抖回调据此过滤自身写入 - LogManager::instance()->logInfo("表映射配置已保存到 cust.json"); - QMessageBox::information(this, "提示", "保存成功!"); - } else { - QMessageBox::critical(this, "错误", "保存到 cust.json 失败!"); - m_isSaving = false; - } - - // 延迟重置保存标记,让文件监控忽略本次写入事件 - QTimer::singleShot(2000, this, [this]() { - m_isSaving = false; - // 重新添加监控路径(文件可能被替换, 目录监控在此期间负责兜底) - refreshWatchPaths(); - }); -} - -void TableMappingPage::onFileChanged(const QString &path) -{ - // Windows 下文件被整文件替换后 watcher 会自动移除该路径,先补挂保证后续事件仍能收到 - if (m_fileWatcher && !m_fileWatcher->files().contains(path) && QFile::exists(path)) { - m_fileWatcher->addPath(path); - } - if (m_isSaving) return; // 自身写入触发的回环,忽略 - - m_reloadTimer->start(); // 防抖: 编辑器保存常触发多次事件,300ms 后统一处理 -} - -void TableMappingPage::onDirChanged(const QString &path) -{ - Q_UNUSED(path); - // 目录事件兜底文件被替换/删除重建的场景,重新补挂文件监控路径 - refreshWatchPaths(); - if (m_isSaving) return; - - m_reloadTimer->start(); -} - -void TableMappingPage::onReloadTimeout() -{ - m_reloadTimer->stop(); - - // mtime 比对过滤自身写入回环: mtime 未变说明是刚才自己保存的内容,无需重载。 - // <= 而非 ==: 覆盖时钟回拨与 FAT/exFAT 2s 粒度截断的场景(外部修改的 mtime - // 只会更新或持平基准, 不会变旧), NTFS 下两者等价 - const QDateTime now = currentFileMtime(); - if (!now.isValid() || now <= m_lastFileMtime) return; - - // 有未保存编辑时询问,避免外部修改静默覆盖用户的编辑 - if (m_dirty) { - QMessageBox::StandardButton reply = QMessageBox::question(this, "文件已变更", - "cust.json 已被其他页面或外部程序修改,重新加载将丢失当前未保存的编辑。是否重新加载?", - QMessageBox::Yes | QMessageBox::No); - if (reply != QMessageBox::Yes) { - // 记住本次版本,避免同一份内容反复弹窗询问 - m_lastFileMtime = now; - refreshWatchPaths(); - return; - } - } - - m_lastFileMtime = now; - LogManager::instance()->logInfo("检测到 cust.json 外部修改,自动刷新数据"); - loadData(); -} - -void TableMappingPage::updateTipStyle() -{ - if (!m_tipLabel) return; - const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); - const QString textColor = ElaThemeColor(mode, BasicText).name(); - m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); -} - -void TableMappingPage::onSearchTextChanged(const QString &text) -{ - static_cast(m_proxyModel)->setFilterText(text); + load(); } diff --git a/src/pages/datamapping/tablemappingpage.h b/src/pages/datamapping/tablemappingpage.h index 2e17cff..42279ba 100644 --- a/src/pages/datamapping/tablemappingpage.h +++ b/src/pages/datamapping/tablemappingpage.h @@ -1,58 +1,17 @@ #ifndef TABLEMAPPINGPAGE_H #define TABLEMAPPINGPAGE_H -#include -#include -#include -#include -#include -#include -#include -#include "ElaTableView.h" +#include "twocolumnkvmappingpage.h" -class ElaLineEdit; -class QLabel; -class QTimer; - -class TableMappingPage : public QWidget +// 维护 cust.json 顶层键 tableCompareUf2_Uf3(UF20→UFT3 表替换映射): +// 两列映射表, 支持 新增(弹窗)/双击修改/删除/搜索过滤, 保存按顶层键顺序保序写回; +// 填充/校验/新增弹窗样板由 TwoColumnKVMappingPage 中间基类收编 +class TableMappingPage : public TwoColumnKVMappingPage { Q_OBJECT public: explicit TableMappingPage(QWidget *parent = nullptr); - ~TableMappingPage(); - -private slots: - void onAddRow(); - void onDeleteRow(); - void onModelItemChanged(QStandardItem *item); - void onSave(); - void onFileChanged(const QString &path); - void onDirChanged(const QString &path); - void onReloadTimeout(); - void onSearchTextChanged(const QString &text); - void updateTipStyle(); - -private: - void initUI(); - void loadData(); - QJsonObject readCustJson(); - bool writeCustJson(const QJsonObject &json, const QStringList &keyOrder, - const QString &orderedKey, const QList> &orderedData); - QString getCustJsonPath() const; - void refreshWatchPaths(); - QDateTime currentFileMtime() const; - - ElaTableView *m_tableView; - QStandardItemModel *m_model; - QSortFilterProxyModel *m_proxyModel; - QFileSystemWatcher *m_fileWatcher; - bool m_isLoading; - bool m_isSaving; - bool m_dirty = false; // 有未保存的编辑(onModelItemChanged 置脏,loadData/onSave 成功清脏) - QLabel *m_tipLabel = nullptr; - QTimer *m_reloadTimer = nullptr; // 外部变更防抖(300ms), 弹窗/重载不再嵌套在 watcher 回调内 - QDateTime m_lastFileMtime; // 上次加载/保存后的文件 mtime, 过滤自身写入回环与目录噪声 }; #endif // TABLEMAPPINGPAGE_H diff --git a/src/pages/datamapping/twocolumnkvmappingpage.cpp b/src/pages/datamapping/twocolumnkvmappingpage.cpp new file mode 100644 index 0000000..e9059d2 --- /dev/null +++ b/src/pages/datamapping/twocolumnkvmappingpage.cpp @@ -0,0 +1,141 @@ +#include "twocolumnkvmappingpage.h" +#include "utils/Constants.h" + +#include +#include +#include +#include +#include +#include +#include +#include "src/utils/custjsonio.h" +#include "ElaPushButton.h" +#include "ElaLineEdit.h" +#include "ElaText.h" + +TwoColumnKVMappingPage::TwoColumnKVMappingPage(const QString &custKey, + const QString &entityShort, + const QString &entityName, + QWidget *parent) + : MappingTablePage(2, {"UF20", "UFT3"}, + QString("搜索 UF20 或 UFT3 %1...").arg(entityName), + "💡 双击表格修改数据", parent) + , m_custKey(custKey) + , m_entityShort(entityShort) + , m_entityName(entityName) +{ +} + +void TwoColumnKVMappingPage::populateModel(const QJsonObject &custJson) +{ + if (!custJson.contains(m_custKey)) + return; + const QJsonObject dataObj = custJson[m_custKey].toObject(); + const QStringList keys = dataObj.keys(); + for (const QString &key : keys) { + QList rowItems; + rowItems.append(new QStandardItem(key)); + rowItems.append(new QStandardItem(dataObj[key].toString())); + m_model->appendRow(rowItems); + } +} + +QHash TwoColumnKVMappingPage::collectMappingData(QJsonObject &custJson, + QString &errorMessage) +{ + // 构建有序的 K-V 映射数据。 + // 重复 key 校验: 就地编辑第一列可能改成已有键(JSON 对象同键去重会静默丢失其中一行), + // onAddRow 只覆盖"新增"入口, 编辑入口必须在此兜底 + QJsonObject dataObj; + QList> orderedData; + for (int row = 0; row < m_model->rowCount(); ++row) { + QStandardItem *keyItem = m_model->item(row, 0); + QStandardItem *valItem = m_model->item(row, 1); + if (!keyItem || keyItem->text().trimmed().isEmpty()) { + // 空键行不再静默丢弃: 静默跳过等于无提示丢数据, 用户无从得知哪行没存上 + errorMessage = QString("第 %1 行的 UF20 %2 为空,请填写或删除该行后再保存!") + .arg(row + 1).arg(m_entityName); + return {}; + } + QString k = keyItem->text().trimmed(); + if (dataObj.contains(k)) { + errorMessage = QString("第 %1 行的 UF20 %2 \"%3\" 与其他行重复,请修改后再保存!") + .arg(row + 1).arg(m_entityName).arg(k); + return {}; + } + QString v = valItem ? valItem->text().trimmed() : ""; + dataObj[k] = v; + orderedData.append({k, v}); + } + custJson[m_custKey] = dataObj; + + return {{m_custKey, + CustJsonIo::formatOrderedJsonObject(orderedData)}}; +} + +void TwoColumnKVMappingPage::onAddRow() +{ + QDialog dialog(this); + dialog.setWindowTitle(QString("添加%1映射").arg(m_entityShort)); + dialog.resize(450, 180); + + QVBoxLayout *layout = new QVBoxLayout(&dialog); + layout->setSpacing(12); + + QHBoxLayout *keyLayout = new QHBoxLayout; + ElaText *keyLabel = new ElaText("UF20:", &dialog); + keyLabel->setTextPointSize(10); + keyLayout->addWidget(keyLabel); + ElaLineEdit *keyEdit = new ElaLineEdit(&dialog); + keyEdit->setPlaceholderText(QString("输入UF20%1").arg(m_entityName)); + keyLayout->addWidget(keyEdit); + layout->addLayout(keyLayout); + + QHBoxLayout *valLayout = new QHBoxLayout; + ElaText *valLabel = new ElaText("UFT3:", &dialog); + valLabel->setTextPointSize(10); + valLayout->addWidget(valLabel); + ElaLineEdit *valEdit = new ElaLineEdit(&dialog); + valEdit->setPlaceholderText(QString("输入UFT3%1").arg(m_entityName)); + valLayout->addWidget(valEdit); + layout->addLayout(valLayout); + + QHBoxLayout *btnLayout = new QHBoxLayout; + ElaPushButton *okBtn = new ElaPushButton("确定", &dialog); + ElaPushButton *cancelBtn = new ElaPushButton("取消", &dialog); + btnLayout->addStretch(); + btnLayout->addWidget(okBtn); + btnLayout->addWidget(cancelBtn); + layout->addLayout(btnLayout); + + connect(okBtn, &ElaPushButton::clicked, &dialog, &QDialog::accept); + connect(cancelBtn, &ElaPushButton::clicked, &dialog, &QDialog::reject); + + if (dialog.exec() != QDialog::Accepted) return; + + QString key = keyEdit->text().trimmed(); + QString val = valEdit->text().trimmed(); + + if (key.isEmpty()) { + QMessageBox::warning(this, "提示", QString("UF20 %1不能为空!").arg(m_entityName)); + return; + } + + for (int row = 0; row < m_model->rowCount(); ++row) { + // 既有行同样按 trimmed 口径比较: 新增输入已 trim, collectMappingData 判重也按 + // trim 后比较, 不 trim 会让" foo"(带空白)与"foo"在新增时漏判、保存时报重复 + if (m_model->item(row, 0) && m_model->item(row, 0)->text().trimmed() == key) { + QMessageBox::warning(this, "提示", + QString("UF20 %1 \"%2\" 已存在!").arg(m_entityName).arg(key)); + return; + } + } + + QList rowItems; + rowItems.append(new QStandardItem(key)); + rowItems.append(new QStandardItem(val)); + m_model->appendRow(rowItems); + // 新增行尚未保存: 传入的 item 已带数据, 入模不触发 itemChanged, + // 需显式置脏纳入外部文件变更保护 + m_dirty = true; +} diff --git a/src/pages/datamapping/twocolumnkvmappingpage.h b/src/pages/datamapping/twocolumnkvmappingpage.h new file mode 100644 index 0000000..68afb43 --- /dev/null +++ b/src/pages/datamapping/twocolumnkvmappingpage.h @@ -0,0 +1,38 @@ +#ifndef TWOCOLUMNKVMAPPINGPAGE_H +#define TWOCOLUMNKVMAPPINGPAGE_H + +#include "mappingtablepage.h" + +// --------------------------------------------------------------------------- +// TwoColumnKVMappingPage:两列 K-V 映射页(表替换/函数替换)的参数化中间基类 +// +// 两页差异仅为 cust.json 顶层键名与实体名词(表/函数),其余 +// populateModel / collectMappingData(含重复 key 校验) / onAddRow(键值输入弹窗) +// 三段样板在此统一实现,子类退化为一个构造函数——避免平行复制时修复漏同步。 +// (历史教训:重复 key 校验只落在表替换拷贝上,函数替换保存同键静默丢行) +// --------------------------------------------------------------------------- +class TwoColumnKVMappingPage : public MappingTablePage +{ + Q_OBJECT + +public: + // custKey: cust.json 顶层键;entityShort: 短名词("表"/"函数",用于弹窗标题); + // entityName: 全名词("表名"/"函数名",用于校验/placeholder 文案) + explicit TwoColumnKVMappingPage(const QString &custKey, + const QString &entityShort, + const QString &entityName, + QWidget *parent = nullptr); + +protected: + void populateModel(const QJsonObject &custJson) override; + QHash collectMappingData(QJsonObject &custJson, + QString &errorMessage) override; + void onAddRow() override; + +private: + QString m_custKey; + QString m_entityShort; + QString m_entityName; +}; + +#endif // TWOCOLUMNKVMAPPINGPAGE_H diff --git a/src/pages/functionsearch/functionsearchpagebase.cpp b/src/pages/functionsearch/functionsearchpagebase.cpp new file mode 100644 index 0000000..cbbcbe7 --- /dev/null +++ b/src/pages/functionsearch/functionsearchpagebase.cpp @@ -0,0 +1,230 @@ +#include "functionsearchpagebase.h" +#include "src/utils/Constants.h" +#include +#include +#include +#include +#include +#include +#include +#include "ElaTheme.h" + +namespace { + // 拆分多关键词: 中英文逗号/分号 + 空白统一视为分隔符 + // 全角字符用字符串字面量替换, 多字节字符不能写成字符常量(会成为 multichar) + const QRegularExpression kWhitespaceRegex(QStringLiteral("\\s+")); // static 级别复用, 避免每次调用重新编译正则 + + QStringList splitKeywords(const QString& text) + { + QString normalized = text; + normalized.replace(QStringLiteral(","), QStringLiteral(" ")) + .replace(QStringLiteral(";"), QStringLiteral(" ")) + .replace(',', ' ') + .replace(';', ' '); + return normalized.split(kWhitespaceRegex, Qt::SkipEmptyParts); + } +} + +FunctionSearchPageBase::FunctionSearchPageBase(const QString &searchPlaceholder, + const QString &defaultTip, + QWidget *parent) + : QWidget(parent) + , m_tipLabel(nullptr) + , m_exactMatch(false) + , m_searchPlaceholder(searchPlaceholder) + , m_defaultTip(defaultTip) +{ + initUI(); +} + +void FunctionSearchPageBase::initUI() +{ + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(30, 30, 30, 30); + layout->setSpacing(20); + + QHBoxLayout *searchLayout = new QHBoxLayout; + searchLayout->setSpacing(10); + + m_searchComboBox = new ElaComboBox; + m_searchComboBox->addItem("函数名", Constants::FunctionSearch::FieldCName); + m_searchComboBox->addItem("功能号", Constants::FunctionSearch::FieldFunctionNo); + searchLayout->addWidget(m_searchComboBox); + + m_searchEdit = new ElaSearchEdit; + m_searchEdit->setPlaceholderText(m_searchPlaceholder); + m_searchEdit->setSearchEditSize(330, 35); + connect(m_searchEdit, &ElaSearchEdit::returnPressed, this, &FunctionSearchPageBase::onSearch); + connect(m_searchEdit, &ElaSearchEdit::searchClicked, this, &FunctionSearchPageBase::onSearch); + connect(m_searchEdit, &ElaSearchEdit::exactMatchChanged, this, &FunctionSearchPageBase::onExactMatchToggled); + searchLayout->addWidget(m_searchEdit); + + searchLayout->addStretch(); + layout->addLayout(searchLayout); + + m_tableModel = new QStandardItemModel(this); + m_tableModel->setColumnCount(3); + m_tableModel->setHorizontalHeaderLabels({"函数名", "英文名", "功能号"}); + + m_resultTable = new ElaTableView; + m_resultTable->setModel(m_tableModel); + m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + m_resultTable->setMinimumHeight(400); + m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows); + m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + layout->addWidget(m_resultTable); + + QHBoxLayout *tipLayout = new QHBoxLayout; + m_tipLabel = new QLabel(m_defaultTip); + // 不开折行: wordWrap(true) 的 QLabel 在布局中会自选偏窄宽度把提示拆成多行, + // 用户要求提示单行显示 + m_tipLabel->setWordWrap(false); + tipLayout->addWidget(m_tipLabel); + tipLayout->addStretch(); + layout->addLayout(tipLayout); + + // tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化 + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &FunctionSearchPageBase::updateTipStyle); + updateTipStyle(); +} + +void FunctionSearchPageBase::enableBatchSelection() +{ + // 扩展多选: Ctrl/Shift 选多行后双击或右键可批量加入转码工作台 + m_resultTable->setSelectionMode(QAbstractItemView::ExtendedSelection); + // 右键菜单: 一键添加功能 + m_resultTable->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_resultTable, &QWidget::customContextMenuRequested, + this, &FunctionSearchPageBase::onContextMenuRequested); + connect(m_resultTable, &QTableView::doubleClicked, + this, &FunctionSearchPageBase::onTableDoubleClicked); +} + +void FunctionSearchPageBase::updateTipStyle() +{ + if (!m_tipLabel) { + return; + } + const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); + const QString textColor = ElaThemeColor(mode, BasicText).name(); + m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); +} + +void FunctionSearchPageBase::onExactMatchToggled(bool checked) +{ + m_exactMatch = checked; +} + +void FunctionSearchPageBase::onTableDoubleClicked(const QModelIndex &index) +{ + if (!index.isValid()) { + return; + } + // 双击已选中多行时按整个选区批量加入(单行时即单条行为); + // 选区意外为空时按双击行兜底 + if (m_resultTable->selectionModel()->selectedRows().isEmpty()) { + m_resultTable->selectRow(index.row()); + } + addSelectedRowsToConvert(); +} + +void FunctionSearchPageBase::onContextMenuRequested(const QPoint &pos) +{ + const QModelIndex index = m_resultTable->indexAt(pos); + if (!index.isValid()) { + return; // 空白区域右键不弹菜单 + } + // 右键行不在选区时将其设为唯一选中行(与常规右键交互一致, 选区内右键则保持多选) + if (!m_resultTable->selectionModel()->isSelected(index)) { + m_resultTable->selectRow(index.row()); + } + QMenu menu(this); + QAction *addAct = menu.addAction("一键添加功能"); + if (menu.exec(m_resultTable->viewport()->mapToGlobal(pos)) == addAct) { + addSelectedRowsToConvert(); + } +} + +void FunctionSearchPageBase::addSelectedRowsToConvert() +{ + QStringList lsNames; + QStringList nonLsNames; + const QModelIndexList selected = m_resultTable->selectionModel()->selectedRows(); + for (const QModelIndex &row : selected) { + const QString funcName = + m_tableModel->data(m_tableModel->index(row.row(), 0)).toString(); + if (funcName.isEmpty()) { + continue; + } + // LS_ 开头 → 全量转码;其余 → 按需转码(由 MainWindow 接到对应工作台) + if (funcName.startsWith(Constants::FuncName::LsPrefix)) { + lsNames.append(funcName); + } else { + nonLsNames.append(funcName); + } + } + // 一次 emit 两个列表, 由 MainWindow 静默分流加入并合并为一个提示框 + emit addFunctionsToConvert(lsNames, nonLsNames); +} + +void FunctionSearchPageBase::onSearch() +{ + // 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集) + const QStringList keywords = splitKeywords(m_searchEdit->text()); + + m_tableModel->removeRows(0, m_tableModel->rowCount()); + + if (keywords.isEmpty()) { + // 空关键字: 页内 tip 反馈, 不弹模态框打断操作 + if (m_tipLabel) { + m_tipLabel->setText("请输入查询内容"); + } + return; + } + + QString field = m_searchComboBox->currentData().toString(); + int noMatchCount = 0; + QList results = searchFunctions(keywords, field, m_exactMatch, &noMatchCount); + + if (results.isEmpty()) { + // 无结果: 页内 tip 反馈 + if (m_tipLabel) { + m_tipLabel->setText("⚠️ 未找到匹配的功能"); + } + return; + } + + // 查询成功: tip 显示查询统计 + 操作提示 + if (m_tipLabel) { + QString tip = QString("已查询 %1 个业务功能函数").arg(keywords.size()); + if (noMatchCount > 0) { + tip += QString("(%1 个无匹配)").arg(noMatchCount); + } + // 统计前缀 + 默认提示(操作说明/批量查询示例) + tip += QString(";%1").arg(m_defaultTip); + m_tipLabel->setText(tip); + } + + // 批量填充期间抑制逐行重绘: OR 查询单次可命中数百行, 每行 appendRow 都触发 + // 视图增量 layout/repaint, 关闭更新后仅在填充完成时整体呈现一次 + m_resultTable->setUpdatesEnabled(false); + for (const FunctionInfo& info : results) { + QList rowItems; + + QStandardItem *cnameItem = new QStandardItem(info.cname); + cnameItem->setFlags(cnameItem->flags() & ~Qt::ItemIsEditable); + rowItems.append(cnameItem); + + QStandardItem *eNameItem = new QStandardItem(info.eName); + eNameItem->setFlags(eNameItem->flags() & ~Qt::ItemIsEditable); + rowItems.append(eNameItem); + + QStandardItem *funcNoItem = new QStandardItem(info.functionNo); + funcNoItem->setFlags(funcNoItem->flags() & ~Qt::ItemIsEditable); + rowItems.append(funcNoItem); + + m_tableModel->appendRow(rowItems); + } + m_resultTable->setUpdatesEnabled(true); +} diff --git a/src/pages/functionsearch/functionsearchpagebase.h b/src/pages/functionsearch/functionsearchpagebase.h new file mode 100644 index 0000000..1140481 --- /dev/null +++ b/src/pages/functionsearch/functionsearchpagebase.h @@ -0,0 +1,66 @@ +#ifndef FUNCTIONSEARCHPAGEBASE_H +#define FUNCTIONSEARCHPAGEBASE_H + +#include +#include +#include "ElaComboBox.h" +#include "components/elasearchedit.h" +#include "ElaTableView.h" +#include "src/utils/datacache.h" +#include "src/utils/Constants.h" + +class QLabel; + +// UF20/UFT3 功能查询页的公共基类: 搜索框/结果表/tip 标签与 onSearch 全流程共用, +// 子类仅提供 searchUF20Functions/searchUFT3Functions 的数据源差异(虚钩子)。 +// UF20 页的批量加入能力(多选/右键/双击)通过 enableBatchSelection() 显式开启。 +class FunctionSearchPageBase : public QWidget +{ + Q_OBJECT + +public: + // searchPlaceholder: 搜索框占位文本; defaultTip: 查询成功时 tip 追加的操作提示 + explicit FunctionSearchPageBase(const QString &searchPlaceholder, + const QString &defaultTip, + QWidget *parent = nullptr); + +signals: + // 一键添加(双击/右键共用): 按 LS_ 前缀分流, LS_ 开头 → 全量转码, 其余 → 按需转码; + // 由 MainWindow 静默加入两个工作台后合并为一个提示框 + void addFunctionsToConvert(const QStringList& lsNames, const QStringList& nonLsNames); + +protected: + // 数据源钩子: 子类分流到 DataCache 的 UF20/UFT3 专属搜索 + virtual QList searchFunctions(const QStringList &keywords, + const QString &field, + bool exactMatch, + int *noMatchCount) const = 0; + // 批量加入转码工作台能力: 表格切多选 + 右键菜单 + 双击触发, + // 需在子类构造体内调用(基类构造期虚派发不可用, 故提供显式开关) + void enableBatchSelection(); + +private slots: + void onSearch(); + void onExactMatchToggled(bool checked); + void onTableDoubleClicked(const QModelIndex &index); + // 右键菜单"一键添加功能": 把当前选中行(可多选)加入转码工作台 + void onContextMenuRequested(const QPoint &pos); + // tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随) + void updateTipStyle(); + +private: + void initUI(); + // 收集当前选中行, 按 LS_ 前缀分流 emit(双击与右键共用) + void addSelectedRowsToConvert(); + + ElaComboBox *m_searchComboBox; + ElaSearchEdit *m_searchEdit; + ElaTableView *m_resultTable; + QStandardItemModel *m_tableModel; + QLabel *m_tipLabel; + bool m_exactMatch; + QString m_searchPlaceholder; + QString m_defaultTip; +}; + +#endif diff --git a/src/pages/functionsearch/uf20functionsearchpage.cpp b/src/pages/functionsearch/uf20functionsearchpage.cpp index 54d172f..d11299f 100644 --- a/src/pages/functionsearch/uf20functionsearchpage.cpp +++ b/src/pages/functionsearch/uf20functionsearchpage.cpp @@ -1,226 +1,22 @@ #include "uf20functionsearchpage.h" -#include "src/utils/datacache.h" -#include -#include -#include -#include -#include -#include -#include -#include "ElaComboBox.h" -#include "ElaTheme.h" namespace { const char* kDefaultTip = "💡 双击或右键选中行加入转码工作台:LS功能 → \"全量转码\";其它功能 → \"按需转码\"。" "批量查询示例:LS_证券参数, LS_股票名单(逗号/空格分隔)"; - - // 拆分多关键词: 中英文逗号/分号 + 空白统一视为分隔符 - // 全角字符用字符串字面量替换, 多字节字符不能写成字符常量(会成为 multichar) - QStringList splitKeywords(const QString& text) - { - QString normalized = text; - normalized.replace(QStringLiteral(","), QStringLiteral(" ")) - .replace(QStringLiteral(";"), QStringLiteral(" ")) - .replace(',', ' ') - .replace(';', ' '); - return normalized.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); - } } UF20FunctionSearchPage::UF20FunctionSearchPage(QWidget *parent) - : QWidget(parent) - , m_tipLabel(nullptr) - , m_exactMatch(false) + : FunctionSearchPageBase("输入查询内容", QString::fromUtf8(kDefaultTip), parent) { - initUI(); + // UF20 页独有: 多选/右键/双击批量加入转码工作台 + enableBatchSelection(); } -UF20FunctionSearchPage::~UF20FunctionSearchPage() +QList UF20FunctionSearchPage::searchFunctions(const QStringList &keywords, + const QString &field, + bool exactMatch, + int *noMatchCount) const { -} - -void UF20FunctionSearchPage::initUI() -{ - QVBoxLayout *layout = new QVBoxLayout(this); - layout->setContentsMargins(30, 30, 30, 30); - layout->setSpacing(20); - - QHBoxLayout *searchLayout = new QHBoxLayout; - searchLayout->setSpacing(10); - - m_searchComboBox = new ElaComboBox; - m_searchComboBox->addItem("函数名", "cname"); - m_searchComboBox->addItem("功能号", "function_no"); - searchLayout->addWidget(m_searchComboBox); - - m_searchEdit = new ElaSearchEdit; - m_searchEdit->setPlaceholderText("输入查询内容"); - m_searchEdit->setSearchEditSize(330, 35); - connect(m_searchEdit, &ElaSearchEdit::returnPressed, this, &UF20FunctionSearchPage::onSearch); - connect(m_searchEdit, &ElaSearchEdit::searchClicked, this, &UF20FunctionSearchPage::onSearch); - connect(m_searchEdit, &ElaSearchEdit::exactMatchChanged, this, &UF20FunctionSearchPage::onExactMatchToggled); - - searchLayout->addWidget(m_searchEdit); - - searchLayout->addStretch(); - layout->addLayout(searchLayout); - - m_tableModel = new QStandardItemModel(this); - m_tableModel->setColumnCount(3); - m_tableModel->setHorizontalHeaderLabels({"函数名", "英文名", "功能号"}); - - m_resultTable = new ElaTableView; - m_resultTable->setModel(m_tableModel); - m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - m_resultTable->setMinimumHeight(400); - m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows); - m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - // 扩展多选: Ctrl/Shift 选多行后双击或右键可批量加入转码工作台 - m_resultTable->setSelectionMode(QAbstractItemView::ExtendedSelection); - // 右键菜单: 一键添加功能 - m_resultTable->setContextMenuPolicy(Qt::CustomContextMenu); - connect(m_resultTable, &QWidget::customContextMenuRequested, - this, &UF20FunctionSearchPage::onContextMenuRequested); - connect(m_resultTable, &QTableView::doubleClicked, this, &UF20FunctionSearchPage::onTableDoubleClicked); - layout->addWidget(m_resultTable); - - QHBoxLayout *tipLayout = new QHBoxLayout; - m_tipLabel = new QLabel(kDefaultTip); - // 不开折行: wordWrap(true) 的 QLabel 在布局中会自选偏窄宽度把提示拆成多行, - // 用户要求提示单行显示 - m_tipLabel->setWordWrap(false); - tipLayout->addWidget(m_tipLabel); - tipLayout->addStretch(); - layout->addLayout(tipLayout); - - // tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化 - connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, - this, &UF20FunctionSearchPage::updateTipStyle); - updateTipStyle(); -} - -void UF20FunctionSearchPage::updateTipStyle() -{ - if (!m_tipLabel) { - return; - } - const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); - const QString textColor = ElaThemeColor(mode, BasicText).name(); - m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); -} - -void UF20FunctionSearchPage::onExactMatchToggled(bool checked) -{ - m_exactMatch = checked; -} - -void UF20FunctionSearchPage::onSearch() -{ - // 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集) - const QStringList keywords = splitKeywords(m_searchEdit->text()); - - m_tableModel->removeRows(0, m_tableModel->rowCount()); - - if (keywords.isEmpty()) { - // 空关键字: 页内 tip 反馈, 不弹模态框打断操作 - if (m_tipLabel) { - m_tipLabel->setText("请输入查询内容"); - } - return; - } - - QString field = m_searchComboBox->currentData().toString(); - int noMatchCount = 0; - QList results = - DataCache::instance()->searchUF20Functions(keywords, field, m_exactMatch, &noMatchCount); - - if (results.isEmpty()) { - // 无结果: 页内 tip 反馈 - if (m_tipLabel) { - m_tipLabel->setText("⚠️ 未找到匹配的功能"); - } - return; - } - - // 查询成功: tip 显示查询统计 + 操作提示 - if (m_tipLabel) { - QString tip = QString("已查询 %1 个业务功能函数").arg(keywords.size()); - if (noMatchCount > 0) { - tip += QString("(%1 个无匹配)").arg(noMatchCount); - } - // 统计前缀 + 默认提示(操作说明/批量查询示例) - tip += QString(";%1").arg(QString::fromUtf8(kDefaultTip)); - m_tipLabel->setText(tip); - } - - for (const FunctionInfo& info : results) { - QList rowItems; - - QStandardItem *cnameItem = new QStandardItem(info.cname); - cnameItem->setFlags(cnameItem->flags() & ~Qt::ItemIsEditable); - rowItems.append(cnameItem); - - QStandardItem *eNameItem = new QStandardItem(info.eName); - eNameItem->setFlags(eNameItem->flags() & ~Qt::ItemIsEditable); - rowItems.append(eNameItem); - - QStandardItem *funcNoItem = new QStandardItem(info.functionNo); - funcNoItem->setFlags(funcNoItem->flags() & ~Qt::ItemIsEditable); - rowItems.append(funcNoItem); - - m_tableModel->appendRow(rowItems); - } -} - -void UF20FunctionSearchPage::onTableDoubleClicked(const QModelIndex &index) -{ - if (!index.isValid()) { - return; - } - // 双击已选中多行时按整个选区批量加入(单行时即单条行为); - // 选区意外为空时按双击行兜底 - if (m_resultTable->selectionModel()->selectedRows().isEmpty()) { - m_resultTable->selectRow(index.row()); - } - addSelectedRowsToConvert(); -} - -void UF20FunctionSearchPage::onContextMenuRequested(const QPoint &pos) -{ - const QModelIndex index = m_resultTable->indexAt(pos); - if (!index.isValid()) { - return; // 空白区域右键不弹菜单 - } - // 右键行不在选区时将其设为唯一选中行(与常规右键交互一致, 选区内右键则保持多选) - if (!m_resultTable->selectionModel()->isSelected(index)) { - m_resultTable->selectRow(index.row()); - } - QMenu menu(this); - QAction *addAct = menu.addAction("一键添加功能"); - if (menu.exec(m_resultTable->viewport()->mapToGlobal(pos)) == addAct) { - addSelectedRowsToConvert(); - } -} - -void UF20FunctionSearchPage::addSelectedRowsToConvert() -{ - QStringList lsNames; - QStringList nonLsNames; - const QModelIndexList selected = m_resultTable->selectionModel()->selectedRows(); - for (const QModelIndex &row : selected) { - const QString funcName = - m_tableModel->data(m_tableModel->index(row.row(), 0)).toString(); - if (funcName.isEmpty()) { - continue; - } - // LS_ 开头 → 全量转码;其余 → 按需转码(由 MainWindow 接到对应工作台) - if (funcName.startsWith("LS_")) { - lsNames.append(funcName); - } else { - nonLsNames.append(funcName); - } - } - // 一次 emit 两个列表, 由 MainWindow 静默分流加入并合并为一个提示框 - emit addFunctionsToConvert(lsNames, nonLsNames); + return DataCache::instance()->searchUF20Functions(keywords, field, exactMatch, noMatchCount); } diff --git a/src/pages/functionsearch/uf20functionsearchpage.h b/src/pages/functionsearch/uf20functionsearchpage.h index 651dce7..8484b8d 100644 --- a/src/pages/functionsearch/uf20functionsearchpage.h +++ b/src/pages/functionsearch/uf20functionsearchpage.h @@ -1,47 +1,20 @@ #ifndef UF20FUNCTIONSEARCHPAGE_H #define UF20FUNCTIONSEARCHPAGE_H -#include -#include -#include "ElaComboBox.h" -#include "components/elasearchedit.h" -#include "ElaTableView.h" +#include "functionsearchpagebase.h" -class QLabel; - -class UF20FunctionSearchPage : public QWidget +class UF20FunctionSearchPage : public FunctionSearchPageBase { Q_OBJECT public: explicit UF20FunctionSearchPage(QWidget *parent = nullptr); - ~UF20FunctionSearchPage(); -signals: - // 一键添加(双击/右键共用): 按 LS_ 前缀分流, LS_ 开头 → 全量转码, 其余 → 按需转码; - // 由 MainWindow 静默加入两个工作台后合并为一个提示框 - void addFunctionsToConvert(const QStringList& lsNames, const QStringList& nonLsNames); - -private slots: - void onSearch(); - void onExactMatchToggled(bool checked); - void onTableDoubleClicked(const QModelIndex &index); - // 右键菜单"一键添加功能": 把当前选中行(可多选)加入转码工作台 - void onContextMenuRequested(const QPoint &pos); - // tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随) - void updateTipStyle(); - -private: - void initUI(); - // 收集当前选中行, 按 LS_ 前缀分流 emit(双击与右键共用) - void addSelectedRowsToConvert(); - - ElaComboBox *m_searchComboBox; - ElaSearchEdit *m_searchEdit; - ElaTableView *m_resultTable; - QStandardItemModel *m_tableModel; - QLabel *m_tipLabel; - bool m_exactMatch; +protected: + QList searchFunctions(const QStringList &keywords, + const QString &field, + bool exactMatch, + int *noMatchCount) const override; }; #endif diff --git a/src/pages/functionsearch/uft3functionsearchpage.cpp b/src/pages/functionsearch/uft3functionsearchpage.cpp index d34b5bf..cff2ca9 100644 --- a/src/pages/functionsearch/uft3functionsearchpage.cpp +++ b/src/pages/functionsearch/uft3functionsearchpage.cpp @@ -1,165 +1,20 @@ #include "uft3functionsearchpage.h" -#include "src/utils/datacache.h" -#include -#include -#include -#include -#include -#include -#include "ElaComboBox.h" -#include "ElaTheme.h" namespace { const char* kDefaultTip = "💡 支持批量查询(逗号/空格分隔),示例:函数A, 函数B"; - - // 拆分多关键词: 中英文逗号/分号 + 空白统一视为分隔符 - // 全角字符用字符串字面量替换, 多字节字符不能写成字符常量(会成为 multichar) - QStringList splitKeywords(const QString& text) - { - QString normalized = text; - normalized.replace(QStringLiteral(","), QStringLiteral(" ")) - .replace(QStringLiteral(";"), QStringLiteral(" ")) - .replace(',', ' ') - .replace(';', ' '); - return normalized.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); - } } UFT3FunctionSearchPage::UFT3FunctionSearchPage(QWidget *parent) - : QWidget(parent) - , m_tipLabel(nullptr) - , m_exactMatch(false) -{ - initUI(); -} - -UFT3FunctionSearchPage::~UFT3FunctionSearchPage() + : FunctionSearchPageBase("输入查询内容(逗号/空格分隔可查多个)", + QString::fromUtf8(kDefaultTip), parent) { } -void UFT3FunctionSearchPage::initUI() +QList UFT3FunctionSearchPage::searchFunctions(const QStringList &keywords, + const QString &field, + bool exactMatch, + int *noMatchCount) const { - QVBoxLayout *layout = new QVBoxLayout(this); - layout->setContentsMargins(30, 30, 30, 30); - layout->setSpacing(20); - - QHBoxLayout *searchLayout = new QHBoxLayout; - searchLayout->setSpacing(10); - - m_searchComboBox = new ElaComboBox; - m_searchComboBox->addItem("函数名", "cname"); - m_searchComboBox->addItem("功能号", "function_no"); - searchLayout->addWidget(m_searchComboBox); - - m_searchEdit = new ElaSearchEdit; - m_searchEdit->setPlaceholderText("输入查询内容(逗号/空格分隔可查多个)"); - m_searchEdit->setSearchEditSize(330, 35); - connect(m_searchEdit, &ElaSearchEdit::returnPressed, this, &UFT3FunctionSearchPage::onSearch); - connect(m_searchEdit, &ElaSearchEdit::searchClicked, this, &UFT3FunctionSearchPage::onSearch); - connect(m_searchEdit, &ElaSearchEdit::exactMatchChanged, this, &UFT3FunctionSearchPage::onExactMatchToggled); - - searchLayout->addWidget(m_searchEdit); - - searchLayout->addStretch(); - layout->addLayout(searchLayout); - - m_tableModel = new QStandardItemModel(this); - m_tableModel->setColumnCount(3); - m_tableModel->setHorizontalHeaderLabels({"函数名", "英文名", "功能号"}); - - m_resultTable = new ElaTableView; - m_resultTable->setModel(m_tableModel); - m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - m_resultTable->setMinimumHeight(400); - m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows); - m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - layout->addWidget(m_resultTable); - - QHBoxLayout *tipLayout = new QHBoxLayout; - m_tipLabel = new QLabel(kDefaultTip); - // 不开折行: wordWrap(true) 的 QLabel 在布局中会自选偏窄宽度把提示拆成多行, - // 用户要求提示单行显示 - m_tipLabel->setWordWrap(false); - tipLayout->addWidget(m_tipLabel); - tipLayout->addStretch(); - layout->addLayout(tipLayout); - - // tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化 - connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, - this, &UFT3FunctionSearchPage::updateTipStyle); - updateTipStyle(); -} - -void UFT3FunctionSearchPage::updateTipStyle() -{ - if (!m_tipLabel) { - return; - } - const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); - const QString textColor = ElaThemeColor(mode, BasicText).name(); - m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); -} - -void UFT3FunctionSearchPage::onExactMatchToggled(bool checked) -{ - m_exactMatch = checked; -} - -void UFT3FunctionSearchPage::onSearch() -{ - // 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集) - const QStringList keywords = splitKeywords(m_searchEdit->text()); - - m_tableModel->removeRows(0, m_tableModel->rowCount()); - - if (keywords.isEmpty()) { - // 空关键字: 页内 tip 反馈, 不弹模态框打断操作 - if (m_tipLabel) { - m_tipLabel->setText("请输入查询内容"); - } - return; - } - - QString field = m_searchComboBox->currentData().toString(); - int noMatchCount = 0; - QList results = - DataCache::instance()->searchUFT3Functions(keywords, field, m_exactMatch, &noMatchCount); - - if (results.isEmpty()) { - // 无结果: 页内 tip 反馈 - if (m_tipLabel) { - m_tipLabel->setText("⚠️ 未找到匹配的功能"); - } - return; - } - - // 查询成功: tip 显示查询统计 + 操作提示 - if (m_tipLabel) { - QString tip = QString("已查询 %1 个业务功能函数").arg(keywords.size()); - if (noMatchCount > 0) { - tip += QString("(%1 个无匹配)").arg(noMatchCount); - } - // 统计前缀 + 默认提示(批量查询说明) - tip += QString(";%1").arg(QString::fromUtf8(kDefaultTip)); - m_tipLabel->setText(tip); - } - - for (const FunctionInfo& info : results) { - QList rowItems; - - QStandardItem *cnameItem = new QStandardItem(info.cname); - cnameItem->setFlags(cnameItem->flags() & ~Qt::ItemIsEditable); - rowItems.append(cnameItem); - - QStandardItem *eNameItem = new QStandardItem(info.eName); - eNameItem->setFlags(eNameItem->flags() & ~Qt::ItemIsEditable); - rowItems.append(eNameItem); - - QStandardItem *funcNoItem = new QStandardItem(info.functionNo); - funcNoItem->setFlags(funcNoItem->flags() & ~Qt::ItemIsEditable); - rowItems.append(funcNoItem); - - m_tableModel->appendRow(rowItems); - } + return DataCache::instance()->searchUFT3Functions(keywords, field, exactMatch, noMatchCount); } diff --git a/src/pages/functionsearch/uft3functionsearchpage.h b/src/pages/functionsearch/uft3functionsearchpage.h index d131ba7..b7339b6 100644 --- a/src/pages/functionsearch/uft3functionsearchpage.h +++ b/src/pages/functionsearch/uft3functionsearchpage.h @@ -1,37 +1,20 @@ #ifndef UFT3FUNCTIONSEARCHPAGE_H #define UFT3FUNCTIONSEARCHPAGE_H -#include -#include -#include "ElaComboBox.h" -#include "components/elasearchedit.h" -#include "ElaTableView.h" +#include "functionsearchpagebase.h" -class QLabel; - -class UFT3FunctionSearchPage : public QWidget +class UFT3FunctionSearchPage : public FunctionSearchPageBase { Q_OBJECT public: explicit UFT3FunctionSearchPage(QWidget *parent = nullptr); - ~UFT3FunctionSearchPage(); -private slots: - void onSearch(); - void onExactMatchToggled(bool checked); - // tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随) - void updateTipStyle(); - -private: - void initUI(); - - ElaComboBox *m_searchComboBox; - ElaSearchEdit *m_searchEdit; - ElaTableView *m_resultTable; - QStandardItemModel *m_tableModel; - QLabel *m_tipLabel; - bool m_exactMatch; +protected: + QList searchFunctions(const QStringList &keywords, + const QString &field, + bool exactMatch, + int *noMatchCount) const override; }; #endif diff --git a/src/pages/metadatupdate/metadatupdatepage.cpp b/src/pages/metadataupdate/metadataupdatepage.cpp similarity index 57% rename from src/pages/metadatupdate/metadatupdatepage.cpp rename to src/pages/metadataupdate/metadataupdatepage.cpp index 6b1717b..e28d0eb 100644 --- a/src/pages/metadatupdate/metadatupdatepage.cpp +++ b/src/pages/metadataupdate/metadataupdatepage.cpp @@ -1,27 +1,60 @@ -#include "metadatupdatepage.h" +#include "metadataupdatepage.h" #include "utils/Constants.h" #include "utils/logmanager.h" #include "utils/datacache.h" -#include "utils/business_strategy.h" #include "ElaTheme.h" #include #include #include #include +#include #include -#include -#include -#include #include const int UPDATE_ITEM_SHOW_COLUMN_COUNT = 3; +namespace { + // 更新条目 → MetadataProcessor 成员函数指针派发表: + // 取代 runUpdateUf2/runUpdateUft3 两份 if/else 镜像, 新增条目只加一行。 + // 复选框清单(showUpdateItems)与本表同源生成, fn == nullptr 表示"接口数据" + // (独立路径: 带目录参数且无论成败均需重载数据缓存)。 + // 顺序即 UI 显示顺序, "数据类型"必须先于"标准字段"(标准字段从缓存库读 datatype 表) + // 任务名/条目名常量: 派发表/依赖确认/任务启动/结果分流多处引用, + // 集中定义防字面量拼写漂移("标准字段"依赖"数据类型", 两处必须严格一致) + const char* const kItemDataType = "数据类型"; + const char* const kItemStdFields = "标准字段"; + const QString kTaskUf20 = QStringLiteral("UF20"); + const QString kTaskUft30 = QStringLiteral("UFT30"); + + + struct TaskEntry { + const char* item; + bool (MetadataProcessor::*fn)(); + }; + const QVector kUf2Entries = { + {kItemDataType, &MetadataProcessor::processUf2DataType}, + {kItemStdFields, &MetadataProcessor::processUf2StdFields}, + {"标准错误号", &MetadataProcessor::processUf2ErrNumber}, + {"接口数据", nullptr}, + {"常量", &MetadataProcessor::processUf2Constants}, + {"菜单功能", &MetadataProcessor::processUf2MenuFunc}, + {"模块", &MetadataProcessor::processUf2Module}, + {"表结构", &MetadataProcessor::processUf2Table}, + }; + const QVector kUft3Entries = { + {kItemDataType, &MetadataProcessor::processUft3DataType}, + {kItemStdFields, &MetadataProcessor::processUft3StdFields}, + {"表结构", &MetadataProcessor::processUft3Table}, + {"接口数据", nullptr}, + }; +} + MetadataUpdatePage::MetadataUpdatePage(QWidget *parent) : ElaScrollPage(parent) { initUI(); _processor = new MetadataProcessor(this); - _processor->initConfig(Constants::toolDir()); + _processor->initConfig(); _watcher = new QFutureWatcher(this); connect(_watcher, &QFutureWatcher::finished, @@ -179,10 +212,10 @@ void MetadataUpdatePage::showEvent(QShowEvent* event) } } - static bool firstShow = true; - if (firstShow) + // 首次显示时加载任务列表(实例级标志, 非函数级 static) + if (m_firstShow) { - firstShow = false; + m_firstShow = false; showUpdateItems(); } } @@ -207,29 +240,26 @@ void MetadataUpdatePage::updateCardStyle() void MetadataUpdatePage::showUpdateItems() { - // 注意: "数据类型" 需排在 "标准字段" 之前, 标准字段解析依赖数据类型(从缓存库读取)。 - QStringList uft3Items = {"数据类型", "标准字段", "表结构", - "接口数据"}; - - QStringList uf20Items = {"数据类型", "标准字段", "标准错误号", - "接口数据", "常量", "菜单功能", "模块", "表结构"}; - - for (int i = 0; i < uft3Items.size(); ++i) { - auto* cbx = new ElaCheckBox(uft3Items[i]); + // 复选框清单直接由派发表生成, 与 runUpdate 的条目派发同源: + // 新增/调整条目只改 kUf2Entries/kUft3Entries 一处, 不再维护两份清单 + int uft3Count = 0; + for (const TaskEntry& e : kUft3Entries) { + auto* cbx = new ElaCheckBox(QString::fromUtf8(e.item)); cbx->setCheckState(Qt::Checked); uft3CheckBoxes.append(cbx); - _updateGridLayout->addWidget(cbx, i / UPDATE_ITEM_SHOW_COLUMN_COUNT + 1, i % UPDATE_ITEM_SHOW_COLUMN_COUNT); + _updateGridLayout->addWidget(cbx, uft3Count / UPDATE_ITEM_SHOW_COLUMN_COUNT + 1, uft3Count % UPDATE_ITEM_SHOW_COLUMN_COUNT); + ++uft3Count; } - for (int i = 0; i < uf20Items.size(); ++i) { - auto* cbx = new ElaCheckBox(uf20Items[i]); + int uf20Count = 0; + for (const TaskEntry& e : kUf2Entries) { + auto* cbx = new ElaCheckBox(QString::fromUtf8(e.item)); cbx->setCheckState(Qt::Checked); uf20CheckBoxes.append(cbx); - _updateUf2GridLayout->addWidget(cbx, i / UPDATE_ITEM_SHOW_COLUMN_COUNT + 1, i % UPDATE_ITEM_SHOW_COLUMN_COUNT); + _updateUf2GridLayout->addWidget(cbx, uf20Count / UPDATE_ITEM_SHOW_COLUMN_COUNT + 1, uf20Count % UPDATE_ITEM_SHOW_COLUMN_COUNT); + ++uf20Count; } - int uft3Count = uft3Items.size(); - int uf20Count = uf20Items.size(); while (uft3Count < uf20Count) { _updateGridLayout->addWidget(new QWidget(this), uft3Count / UPDATE_ITEM_SHOW_COLUMN_COUNT + 1, uft3Count % UPDATE_ITEM_SHOW_COLUMN_COUNT); ++uft3Count; @@ -242,227 +272,148 @@ void MetadataUpdatePage::showUpdateItems() void MetadataUpdatePage::onUF20SelectAll() { - for (auto* cbx : uf20CheckBoxes) { - cbx->setChecked(true); - } + setCheckedForGroup(uf20CheckBoxes, true); } void MetadataUpdatePage::onUF20SelectNone() { - for (auto* cbx : uf20CheckBoxes) { - cbx->setChecked(false); - } + setCheckedForGroup(uf20CheckBoxes, false); } void MetadataUpdatePage::onUpdateUF20() { - QStringList selectedItems; - for (auto* cbx : uf20CheckBoxes) { - if (cbx->isChecked()) { - selectedItems << cbx->text(); - } - } - - if (selectedItems.isEmpty()) { - QMessageBox::warning(this, "提示", "请选择至少一项更新内容"); - return; - } - - _uf2UpdateBtn->setEnabled(false); - _uf2AllBtn->setEnabled(false); - _uf2CancelBtn->setEnabled(false); - _progressBar->setValue(0); - _processor->setForceFull(_forceFullCheckBox->isChecked()); - - startUpdate(selectedItems, "UF20"); + updateGroup(uf20CheckBoxes, kTaskUf20); } void MetadataUpdatePage::onUFT3SelectAll() { - for (auto* cbx : uft3CheckBoxes) { - cbx->setChecked(true); - } + setCheckedForGroup(uft3CheckBoxes, true); } void MetadataUpdatePage::onUFT3SelectNone() { - for (auto* cbx : uft3CheckBoxes) { - cbx->setChecked(false); - } + setCheckedForGroup(uft3CheckBoxes, false); } void MetadataUpdatePage::onUpdateUFT3() +{ + updateGroup(uft3CheckBoxes, kTaskUft30); +} + +// 全选/全不选/更新 六个镜像槽的公共实现(仅复选框组与任务名不同) +void MetadataUpdatePage::setCheckedForGroup(QList& boxes, bool checked) +{ + for (auto* cbx : boxes) { + cbx->setChecked(checked); + } +} + +QStringList MetadataUpdatePage::collectCheckedItems(QList& boxes) { QStringList selectedItems; - for (auto* cbx : uft3CheckBoxes) { + for (auto* cbx : boxes) { if (cbx->isChecked()) { selectedItems << cbx->text(); } } + return selectedItems; +} +void MetadataUpdatePage::updateGroup(QList& boxes, const QString& taskName) +{ + const QStringList selectedItems = collectCheckedItems(boxes); if (selectedItems.isEmpty()) { QMessageBox::warning(this, "提示", "请选择至少一项更新内容"); return; } - - _uft3UpdateBtn->setEnabled(false); - _uft3AllBtn->setEnabled(false); - _uft3CancelBtn->setEnabled(false); - _progressBar->setValue(0); - _processor->setForceFull(_forceFullCheckBox->isChecked()); - - startUpdate(selectedItems, "UFT30"); + if (!confirmStdFieldDependency(selectedItems)) { + return; + } + startUpdate(selectedItems, taskName); } -UpdateResult MetadataUpdatePage::runUpdateUf2(const QStringList& items) +// "标准字段"解析从缓存库读 datatype 表(为空时字段缺失数据类型信息): +// 勾选标准字段而未勾选数据类型时弹确认, 避免无意间产出不完整数据 +bool MetadataUpdatePage::confirmStdFieldDependency(const QStringList& selectedItems) { - // 防御性排序: "数据类型" 必须先于 "标准字段"(标准字段从缓存库读 datatype 表)。 - // 默认复选框顺序已正确, 此处兜底防止布局顺序调整后悄悄颠倒 - QStringList ordered = items; - const int dtIdx = ordered.indexOf("数据类型"); - if (dtIdx > 0) - ordered.move(dtIdx, 0); + if (!selectedItems.contains(QString::fromUtf8(kItemStdFields)) + || selectedItems.contains(QString::fromUtf8(kItemDataType))) { + return true; + } + const auto ret = QMessageBox::question(this, "提示", + "标准字段解析依赖\"数据类型\"缓存表,当前未勾选\"数据类型\",\n" + "标准字段将缺失数据类型信息。\n是否仍要继续?", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + return ret == QMessageBox::Yes; +} + +UpdateResult MetadataUpdatePage::runUpdate(const QStringList& items, const QString& taskName) +{ + const bool isUf2 = (taskName == kTaskUf20); + const QVector& entries = isUf2 ? kUf2Entries : kUft3Entries; + + // 勾选项按派发表顺序执行: 表内顺序即依赖顺序("数据类型"先于"标准字段", + // 标准字段从缓存库读 datatype 表), 用户勾选顺序不参与执行次序, + // 无需再对勾选清单做排序兜底 + QVector ordered; + const QSet selected(items.cbegin(), items.cend()); + for (const TaskEntry& e : entries) { + if (selected.contains(QString::fromUtf8(e.item))) { + ordered.append(e); + } + } bool success = true; bool interfaceUpdated = false; + bool cancelled = false; + QStringList failedItems; // 失败数据集明细, 汇总落日志便于定位 int itemCount = items.size(); int idx = 0; - for (const QString& item : ordered) { - if (_processor->isCancelRequested()) break; // 页面析构触发的协作式取消 + for (const TaskEntry& e : ordered) { + if (_processor->isCancelRequested()) { // 页面析构触发的协作式取消 + cancelled = true; + break; + } emit sigProgress((itemCount == 1) ? 0 : (idx * 100 / itemCount), - QString("正在更新UF20 - %1").arg(item)); + QString("正在更新%1 - %2").arg(taskName, e.item)); - if (item == "标准字段") { - if (!processUf2StdFields()) success = false; - } else if (item == "数据类型") { - if (!processUf2DataType()) success = false; - } else if (item == "标准错误号") { - if (!processUf2ErrNumber()) success = false; - } else if (item == "接口数据") { - if (!processUf2Interface(Constants::toolDir())) { - success = false; - } + if (!e.fn) { + // 接口条目独立路径: 带目录参数, 且无论成败均标记需重载数据缓存 + const bool ok = isUf2 ? processUf2Interface() + : processUft3Interface(); + if (!ok) { success = false; failedItems << QString::fromUtf8(e.item); } interfaceUpdated = true; - } else if (item == "常量") { - if (!processUf2Constants()) success = false; - } else if (item == "菜单功能") { - if (!processUf2MenuFunc()) success = false; - } else if (item == "模块") { - if (!processUf2Module()) success = false; - } else if (item == "表结构") { - if (!processUf2Table()) success = false; + } else { + if (!(_processor->*e.fn)()) { success = false; failedItems << QString::fromUtf8(e.item); } } ++idx; } + if (!failedItems.isEmpty()) { + LogManager::instance()->logError( + QString("%1 以下数据集处理失败: %2").arg(taskName, failedItems.join("、"))); + } + // 数据缓存重载放到主线程执行(DataCache 单例非线程安全),见 onWorkerFinished - return UpdateResult{success, interfaceUpdated}; + return UpdateResult{success, interfaceUpdated, cancelled}; } -UpdateResult MetadataUpdatePage::runUpdateUft3(const QStringList& items) +bool MetadataUpdatePage::processUf2Interface() { - // 防御性排序: "数据类型" 必须先于 "标准字段"(标准字段从缓存库读 datatype 表)。 - // 默认复选框顺序已正确, 此处兜底防止布局顺序调整后悄悄颠倒 - QStringList ordered = items; - const int dtIdx = ordered.indexOf("数据类型"); - if (dtIdx > 0) - ordered.move(dtIdx, 0); - - bool success = true; - bool interfaceUpdated = false; - int itemCount = items.size(); - int idx = 0; - - for (const QString& item : ordered) { - if (_processor->isCancelRequested()) break; // 页面析构触发的协作式取消 - - emit sigProgress((itemCount == 1) ? 0 : (idx * 100 / itemCount), - QString("正在更新UFT30 - %1").arg(item)); - - if (item == "标准字段") { - if (!processUft3StdFields()) success = false; - } else if (item == "数据类型") { - if (!processUft3DataType()) success = false; - } else if (item == "表结构") { - if (!processUft3Table()) success = false; - } else if (item == "接口数据") { - if (!processUft3Interface(Constants::toolDir())) { - success = false; - } - interfaceUpdated = true; - } - ++idx; - } - - return UpdateResult{success, interfaceUpdated}; + return _processor->processUf2Interfaces(); } -bool MetadataUpdatePage::processUf2StdFields() +bool MetadataUpdatePage::processUft3Interface() { - return _processor->processUf2StdFields(); -} - -bool MetadataUpdatePage::processUf2DataType() -{ - return _processor->processUf2DataType(); -} - -bool MetadataUpdatePage::processUf2ErrNumber() -{ - return _processor->processUf2ErrNumber(); -} - -bool MetadataUpdatePage::processUf2Interface(const QString& basePath) -{ - return _processor->processUf2Interfaces(basePath); -} - -bool MetadataUpdatePage::processUf2Constants() -{ - return _processor->processUf2Constants(); -} - -bool MetadataUpdatePage::processUf2MenuFunc() -{ - return _processor->processUf2MenuFunc(); -} - -bool MetadataUpdatePage::processUf2Module() -{ - return _processor->processUf2Module(); -} - -bool MetadataUpdatePage::processUf2Table() -{ - return _processor->processUf2Table(); -} - -bool MetadataUpdatePage::processUft3StdFields() -{ - return _processor->processUft3StdFields(); -} - -bool MetadataUpdatePage::processUft3DataType() -{ - return _processor->processUft3DataType(); -} - -bool MetadataUpdatePage::processUft3Table() -{ - return _processor->processUft3Table(); -} - -bool MetadataUpdatePage::processUft3Interface(const QString& basePath) -{ - return _processor->processUft3Interfaces(basePath); + return _processor->processUft3Interfaces(); } void MetadataUpdatePage::startUpdate(const QStringList& items, const QString& taskName) { - // 防御:若上一个任务仍在运行则不再启动(按钮已禁用,正常情况下不会触发)。 - // 必须先于状态改写, 避免防御触发时已污染进度条/任务名等 UI 状态 + // 重入守卫: 跨任务组也互斥(UF20 运行中点"更新UFT30"同样拒绝)。 + // 必须最前执行, 之后才能改写进度条/任务名/按钮等 UI 状态 if (_watcher->isRunning()) { return; } @@ -472,40 +423,43 @@ void MetadataUpdatePage::startUpdate(const QStringList& items, const QString& ta _progressBar->setValue(0); _processLabel->setText(QString("正在准备更新%1...").arg(taskName)); - _processor->resetCancel(); // 重置取消标志,保证新任务不被上一次的请求误杀 + // 统一禁用双侧全部任务入口: 任何一组运行期间另一组也不得启动, + // 由 onUpdateFinished 统一恢复 + _uf2UpdateBtn->setEnabled(false); + _uf2AllBtn->setEnabled(false); + _uf2CancelBtn->setEnabled(false); + _uft3UpdateBtn->setEnabled(false); + _uft3AllBtn->setEnabled(false); + _uft3CancelBtn->setEnabled(false); + _forceFullCheckBox->setEnabled(false); - if (taskName == "UF20") { - _watcher->setFuture(QtConcurrent::run([this, items]() { - try { - return runUpdateUf2(items); - } catch (const std::exception& e) { - // 异常存入 QFuture 后 _watcher->result() 会重抛导致 std::terminate, - // 必须在工作体内捕获并降级为失败结果。 - // needReload 仍置 true: 各 process* 逐数据集独立落库, 异常前已成功 - // 落库的部分必须刷新内存功能缓存/静态 cname 缓存, 否则本轮已更新的 - // 接口在功能搜索中不可见, 直至下次成功更新才一致 - LogManager::instance()->logError( - QString("UF20 元数据更新任务异常: %1").arg(e.what())); - return UpdateResult{false, true}; - } catch (...) { - LogManager::instance()->logError("UF20 元数据更新任务发生未知异常"); - return UpdateResult{false, true}; - } - })); - } else { - _watcher->setFuture(QtConcurrent::run([this, items]() { - try { - return runUpdateUft3(items); - } catch (const std::exception& e) { - LogManager::instance()->logError( - QString("UFT3 元数据更新任务异常: %1").arg(e.what())); - return UpdateResult{false, true}; - } catch (...) { - LogManager::instance()->logError("UFT3 元数据更新任务发生未知异常"); - return UpdateResult{false, true}; - } - })); - } + // setForceFull 必须在守卫通过后、worker 启动前写入: + // m_forceFull 非原子, 运行中写与 worker 读无同步即构成数据竞争 + _processor->setForceFull(_forceFullCheckBox->isChecked()); + + _processor->resetCancel(); // 重置取消标志,保证新任务不被上一次的请求误杀 + _processor->beginUpdateRun(); // 清空单运行内的 dirMtime 缓存,新运行重扫感知文件变更 + + // UF20/UFT3 两段启动 lambda 曾逐行镜像, 已合并: + // 差异只剩 runUpdate 内部的条目派发表, 此处按 taskName 透传即可 + _watcher->setFuture(QtConcurrent::run([this, items, taskName]() { + try { + return runUpdate(items, taskName); + } catch (const std::exception& e) { + // 异常存入 QFuture 后 _watcher->result() 会重抛导致 std::terminate, + // 必须在工作体内捕获并降级为失败结果。 + // needReload 仍置 true: 各 process* 逐数据集独立落库, 异常前已成功 + // 落库的部分必须刷新内存功能缓存/静态 cname 缓存, 否则本轮已更新的 + // 接口在功能搜索中不可见, 直至下次成功更新才一致 + LogManager::instance()->logError( + QString("%1 元数据更新任务异常: %2").arg(taskName, e.what())); + return UpdateResult{false, true, false}; + } catch (...) { + LogManager::instance()->logError( + QString("%1 元数据更新任务发生未知异常").arg(taskName)); + return UpdateResult{false, true, false}; + } + })); } void MetadataUpdatePage::onWorkerFinished() @@ -525,14 +479,28 @@ void MetadataUpdatePage::onWorkerFinished() // 数据缓存重载必须在主线程执行(DataCache 单例与静态 cnameCache 非线程安全) if (r.needReload) { _processLabel->setText("正在更新数据缓存..."); - // 通过业务策略工厂按当前任务选择对应版本的配置重载,取代 UF20/UFT3 if/else - createBusinessStrategy(m_currentTaskName)->reloadConfig(); + // 按当前任务选择对应版本的配置重载 + if (m_currentTaskName.compare(kTaskUf20, Qt::CaseInsensitive) == 0) { + UF2ConfigReader::reloadUF20Config(); + } else { + UFT3ConfigReader::reloadUFT3Config(); + } // 元数据落库后重建功能查询内存缓存, 使后续 UF20/UFT3 功能搜索走缓存而非全表扫描 DataCache::instance()->refreshFunctionCache(); } - onUpdateFinished(r.success, QString("%1更新完成").arg(m_currentTaskName)); - _processLabel->setText(QString("%1更新完成(总耗时: %2)").arg(m_currentTaskName, elapsedStr)); + // 提示文案按结果区分: 失败时不得谎报"更新完成", 取消时不报"失败"。 + // 具体失败原因由各 process* 写日志, 弹窗引导用户查看日志 + QString tip; + if (r.cancelled) { + tip = QString("%1更新已取消(总耗时: %2)").arg(m_currentTaskName, elapsedStr); + } else { + tip = QString("%1%2(总耗时: %3)") + .arg(m_currentTaskName, + r.success ? "更新完成" : "更新失败,详见日志", + elapsedStr); + } + onUpdateFinished(!r.cancelled && r.success, tip); } void MetadataUpdatePage::onUpdateProgress(int progress, const QString& message) @@ -552,6 +520,7 @@ void MetadataUpdatePage::onUpdateFinished(bool success, const QString& message) _uft3UpdateBtn->setEnabled(true); _uft3AllBtn->setEnabled(true); _uft3CancelBtn->setEnabled(true); + _forceFullCheckBox->setEnabled(true); if (success) { QMessageBox::information(this, "提示", message); diff --git a/src/pages/metadatupdate/metadatupdatepage.h b/src/pages/metadataupdate/metadataupdatepage.h similarity index 66% rename from src/pages/metadatupdate/metadatupdatepage.h rename to src/pages/metadataupdate/metadataupdatepage.h index 5ca2826..8288113 100644 --- a/src/pages/metadatupdate/metadatupdatepage.h +++ b/src/pages/metadataupdate/metadataupdatepage.h @@ -11,14 +11,15 @@ #include "utils/uft3configreader.h" #include #include -#include #include #include -// 后台线程执行结果:success 表示整体是否成功,needReload 表示是否需要重新加载数据缓存 +// 后台线程执行结果:success 表示整体是否成功,needReload 表示是否需要重新加载数据缓存, +// cancelled 表示任务被协作式取消中止(页面析构触发), 收尾文案区分"取消"与"失败" struct UpdateResult { bool success; bool needReload; + bool cancelled{false}; }; class MetadataUpdatePage : public ElaScrollPage @@ -53,25 +54,23 @@ private: void showUpdateItems(); void updateCardStyle(); // 根据 Ela 主题(浅色/深色)刷新卡片背景样式 - // 后台线程执行体(由 QtConcurrent 调用,禁止直接操作 UI) - UpdateResult runUpdateUf2(const QStringList& items); - UpdateResult runUpdateUft3(const QStringList& items); + // 后台线程执行体(由 QtConcurrent 调用,禁止直接操作 UI): + // 条目名 → MetadataProcessor 成员函数指针查表派发, 接口条目独立处理 + UpdateResult runUpdate(const QStringList& items, const QString& taskName); // 在主线程启动后台任务 void startUpdate(const QStringList& items, const QString& taskName); - bool processUf2StdFields(); - bool processUf2DataType(); - bool processUf2ErrNumber(); - bool processUf2Constants(); - bool processUf2MenuFunc(); - bool processUf2Module(); - bool processUf2Table(); - bool processUf2Interface(const QString& basePath); + // "标准字段"依赖"数据类型"缓存表: 勾选标准字段而未勾选数据类型时弹确认, + // 返回 false 表示用户放弃本次更新 + bool confirmStdFieldDependency(const QStringList& selectedItems); - bool processUft3StdFields(); - bool processUft3DataType(); - bool processUft3Table(); - bool processUft3Interface(const QString& basePath); + // 全选/全不选/更新 六个镜像槽的公共实现(仅复选框组与任务名不同) + void setCheckedForGroup(QList& boxes, bool checked); + QStringList collectCheckedItems(QList& boxes); + void updateGroup(QList& boxes, const QString& taskName); + + bool processUf2Interface(); + bool processUft3Interface(); QGridLayout* _updateGridLayout{nullptr}; QGridLayout* _updateUf2GridLayout{nullptr}; @@ -93,6 +92,8 @@ private: QFutureWatcher* _watcher{nullptr}; QElapsedTimer _updateTimer; QString m_currentTaskName; // "UF20" / "UFT30",用于区分当前任务 + bool m_firstShow{true}; // 首次 showEvent 时加载任务列表(成员变量而非函数级 static: + // static 跨所有实例共享, 页面重建后新实例首屏将不再加载) }; #endif diff --git a/src/pages/nonlsconvert/nonlsconvertpage.cpp b/src/pages/nonlsconvert/nonlsconvertpage.cpp index 1e41994..6092852 100644 --- a/src/pages/nonlsconvert/nonlsconvertpage.cpp +++ b/src/pages/nonlsconvert/nonlsconvertpage.cpp @@ -1,30 +1,18 @@ #include "nonlsconvertpage.h" #include "src/utils/Constants.h" -#include "src/utils/uf2configreader.h" #include "src/utils/logmanager.h" #include -#include -#include #include -#include #include #include -#include -#include #include #include #include -#include +#include +#include #include -#include -#include -#include #include -#include -#include -#include #include -#include "ElaPushButton.h" #include "ElaComboBox.h" #include "ElaTheme.h" @@ -36,7 +24,7 @@ void adviseAboutLsNames(QWidget *parent, const QStringList &names) QStringList lsNames; for (const QString &name : names) { const QString trimmed = name.trimmed(); - if (trimmed.startsWith("LS_") && !lsNames.contains(trimmed)) { + if (trimmed.startsWith(Constants::FuncName::LsPrefix) && !lsNames.contains(trimmed)) { lsNames.append(trimmed); } } @@ -47,13 +35,13 @@ void adviseAboutLsNames(QWidget *parent, const QStringList &names) } } -// 清洗清单项:去首尾空白;若误带 .service_design 后缀则剥离(保持短名形态) +// 清洗清单项:去首尾空白;若误带设计器扩展名后缀则剥离(保持短名形态) QString normalizeNonLsName(const QString &raw) { QString name = raw.trimmed(); - const QString suffix = ".service_design"; + const QString suffix = Constants::DesignExts::Service; if (name.endsWith(suffix)) { - name.chop(static_cast(strlen(".service_design"))); + name.chop(suffix.size()); name = name.trimmed(); } return name; @@ -68,67 +56,18 @@ constexpr int WildcardDefaultValue = RecursionYes; // 清单表格列定义 enum TableCol { ColName = 0, ColExists = 1, ColRecurse = 2, ColAction = 3 }; -constexpr int ActionColumnWidth = 80; -constexpr int RecurseColumnWidth = 100; } // namespace -class NonLsStatusDelegate : public QStyledItemDelegate -{ -public: - explicit NonLsStatusDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} - - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override - { - QVariant bgColor = index.data(Qt::BackgroundRole); - QVariant fgColor = index.data(Qt::ForegroundRole); - - QStyleOptionViewItem opt = option; - initStyleOption(&opt, index); - - if (bgColor.canConvert()) { - painter->fillRect(opt.rect, bgColor.value()); - } - - if (fgColor.canConvert()) { - painter->setPen(fgColor.value().color()); - } - - painter->drawText(opt.rect, opt.displayAlignment, opt.text); - } -}; - NonLsConvertPage::NonLsConvertPage(QWidget *parent) - : QWidget(parent) + : ConvertWorkbenchBase(parent) { - initUI(); - loadFromCustNonLsJson(); - - m_reloadTimer = new QTimer(this); - m_reloadTimer->setSingleShot(true); - m_reloadTimer->setInterval(300); - connect(m_reloadTimer, &QTimer::timeout, this, &NonLsConvertPage::onReloadTimeout); - - m_lastFileMtime = currentFileMtime(); - setupConfigWatcher(); - - // 灰字说明/进度标签跟随主题明暗切换 - connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, - this, &NonLsConvertPage::updateTipStyle); - updateTipStyle(); + // 两阶段初始化: 构造体内调用, 确保界面钩子(loadConfig/applyPageLayout 等)派发到本类 + initialize(); } -NonLsConvertPage::~NonLsConvertPage() +void NonLsConvertPage::applyPageLayout(QVBoxLayout *layout) { - if (m_configWatcher) { - m_configWatcher->removePaths(m_configWatcher->files()); - m_configWatcher->removePaths(m_configWatcher->directories()); - } -} - -void NonLsConvertPage::initUI() -{ - QVBoxLayout *layout = new QVBoxLayout(this); layout->setContentsMargins(30, 20, 30, 30); layout->setSpacing(14); @@ -143,399 +82,127 @@ void NonLsConvertPage::initUI() m_tipLabel->setTextFormat(Qt::RichText); m_tipLabel->setWordWrap(true); layout->addWidget(m_tipLabel); - - QGroupBox *funcBox = new QGroupBox("转码功能"); - QVBoxLayout *funcLayout = new QVBoxLayout(funcBox); - funcLayout->setSpacing(10); - - m_tableModel = new QStandardItemModel(this); - m_tableModel->setColumnCount(4); - m_tableModel->setHorizontalHeaderLabels({"UF20函数名", "是否存在", "是否递归", "操作"}); - - m_funcTable = new ElaTableView; - m_funcTable->setModel(m_tableModel); - m_funcTable->setMinimumHeight(180); - m_funcTable->setSelectionBehavior(QAbstractItemView::SelectRows); - m_funcTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Fixed); - m_funcTable->horizontalHeader()->setStretchLastSection(false); - m_funcTable->setItemDelegateForColumn(ColExists, new NonLsStatusDelegate(this)); - funcLayout->addWidget(m_funcTable); - - QHBoxLayout *tableBtnLayout = new QHBoxLayout; - ElaPushButton *addBtn = new ElaPushButton("添加"); - connect(addBtn, &QPushButton::clicked, this, &NonLsConvertPage::onAddFunction); - ElaPushButton *removeBtn = new ElaPushButton("删除选中"); - connect(removeBtn, &QPushButton::clicked, this, &NonLsConvertPage::onRemoveFunction); - ElaPushButton *clearBtn = new ElaPushButton("清空列表"); - connect(clearBtn, &QPushButton::clicked, this, &NonLsConvertPage::onClearTable); - tableBtnLayout->addWidget(addBtn); - tableBtnLayout->addStretch(); - tableBtnLayout->addWidget(removeBtn); - tableBtnLayout->addWidget(clearBtn); - funcLayout->addLayout(tableBtnLayout); - - layout->addWidget(funcBox); - - QGroupBox *progressBox = new QGroupBox("转换进度"); - progressBox->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - QVBoxLayout *progressLayout = new QVBoxLayout(progressBox); - progressLayout->setSpacing(10); - - m_progressLabel = new ElaText("等待开始..."); - m_progressLabel->setFixedHeight(40); - m_progressLabel->setWordWrap(false); - m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - progressLayout->addWidget(m_progressLabel); - - m_progressBar = new ElaProgressBar; - m_progressBar->setRange(0, 100); - m_progressBar->setValue(0); - m_progressBar->setFixedHeight(25); - m_progressBar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - progressLayout->addWidget(m_progressBar); - - layout->addWidget(progressBox); - - QHBoxLayout *btnLayout = new QHBoxLayout; - m_startBtn = new QPushButton("开始转换"); - m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); - connect(m_startBtn, &QPushButton::clicked, this, &NonLsConvertPage::onStartConvert); - btnLayout->addStretch(); - btnLayout->addWidget(m_startBtn); - layout->addLayout(btnLayout); } -void NonLsConvertPage::onProgressUpdate(int progress, const QString &status) +void NonLsConvertPage::applyTipStyle(const QString &textColor) { - m_progressBar->setValue(progress); - - if (!status.isEmpty()) { - QString displayStatus = status; - if (displayStatus.length() > 80) { - displayStatus = displayStatus.left(80) + "..."; - } - m_progressLabel->setText(displayStatus); - } -} - -bool NonLsConvertPage::checkFunctionExists(const QString &funcName) -{ - return UF2ConfigReader::checkFuncExistsStatic(funcName); -} - -void NonLsConvertPage::updateTable() -{ - // 不使用 blockSignals:QTableView 依赖 rowsRemoved/rowsInserted 信号更新内部布局缓存, - // 若屏蔽会导致新增行不可见。m_recursionMap 已预填充,值相等时无需写盘。 - m_tableModel->removeRows(0, m_tableModel->rowCount()); - - for (int i = 0; i < m_funcList.size(); ++i) { - const QString funcName = m_funcList[i]; - - QStandardItem *nameItem = new QStandardItem(funcName); - nameItem->setFlags(nameItem->flags() & ~Qt::ItemIsEditable); - // LS_ 开头的函数给出黄色提示(不拦截) - if (funcName.startsWith("LS_")) { - nameItem->setData(QBrush(QColor(196, 126, 10)), Qt::ForegroundRole); - } - - bool exists = checkFunctionExists(funcName); - QString statusText = exists ? "是" : "否"; - - QStandardItem *statusItem = new QStandardItem(statusText); - statusItem->setFlags(statusItem->flags() & ~Qt::ItemIsEditable); - statusItem->setTextAlignment(Qt::AlignCenter); - - if (exists) { - statusItem->setData(QBrush(QColor(144, 238, 144)), Qt::BackgroundRole); - statusItem->setData(QBrush(QColor(0, 100, 0)), Qt::ForegroundRole); - } else { - statusItem->setData(QBrush(QColor(255, 182, 193)), Qt::BackgroundRole); - statusItem->setData(QBrush(QColor(139, 0, 0)), Qt::ForegroundRole); - } - - // “是否递归”以行尾下拉呈现:否=0、是=-1(与 cust_nonls.json convert.dir 值一致) - QStandardItem *recItem = new QStandardItem(""); - recItem->setFlags(recItem->flags() & ~Qt::ItemIsEditable); - recItem->setTextAlignment(Qt::AlignCenter); - - QStandardItem *actionItem = new QStandardItem(""); - actionItem->setFlags(actionItem->flags() & ~Qt::ItemIsEditable); - - QList rowItems; - rowItems.append(nameItem); - rowItems.append(statusItem); - rowItems.append(recItem); - rowItems.append(actionItem); - m_tableModel->appendRow(rowItems); - - // “是否递归”下拉控件:否/是,切换即写回内存映射并持久化 - ElaComboBox *recCombo = new ElaComboBox; - recCombo->addItem("否", RecursionNo); - recCombo->addItem("是", RecursionYes); - recCombo->setCurrentIndex( - (m_recursionMap.value(funcName, RecursionNo) == RecursionYes) ? 1 : 0); - connect(recCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this, funcName](int index) { - const int recursion = (index == 1) ? RecursionYes : RecursionNo; - if (m_recursionMap.value(funcName, RecursionNo) == recursion) { - return; - } - m_recursionMap.insert(funcName, recursion); - saveToCustNonLsJson(m_funcList); - }); - m_funcTable->setIndexWidget(m_tableModel->index(i, ColRecurse), recCombo); - - QPushButton *editBtn = new QPushButton; - editBtn->setIcon(QIcon(":/resources/images/bianji.png")); - editBtn->setIconSize(QSize(20, 20)); - editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;"); - editBtn->setToolTip("修改"); - // QueuedConnection: onEditRow 经 loadFromCustNonLsJson 重建表格会析构 editBtn 自身, - // 同步回调返回到按钮内部事件处理时构成 use-after-free, 故延迟到事件循环执行。 - // 捕获函数名而非行号且以按钮为接收者: 排队前表格可能被重建, 行号会指向错误行; - // 函数名是稳定键, 且按钮析构时过期的排队请求随之失效 - connect(editBtn, &QPushButton::clicked, editBtn, [this, funcName]() { - onEditRowByName(funcName); - }, Qt::QueuedConnection); - m_funcTable->setIndexWidget(m_tableModel->index(i, ColAction), editBtn); - } - - int totalWidth = m_funcTable->viewport()->width(); - const int fixedWidth = ActionColumnWidth + RecurseColumnWidth; - const int rest = totalWidth - fixedWidth; - m_funcTable->setColumnWidth(ColName, rest * 8 / 10); - m_funcTable->setColumnWidth(ColExists, rest * 2 / 10); - m_funcTable->setColumnWidth(ColRecurse, RecurseColumnWidth); - m_funcTable->setColumnWidth(ColAction, ActionColumnWidth); - - // 视图刷新保险:强制重绘并滚动到顶部,确保新增行可见 - m_funcTable->viewport()->update(); - m_funcTable->resizeRowsToContents(); - m_funcTable->scrollToTop(); - m_funcTable->clearSelection(); -} - -void NonLsConvertPage::resizeEvent(QResizeEvent *event) -{ - QWidget::resizeEvent(event); - if (m_funcTable) { - int totalWidth = m_funcTable->width(); - const int fixedWidth = ActionColumnWidth + RecurseColumnWidth; - const int rest = totalWidth - fixedWidth; - m_funcTable->setColumnWidth(ColName, rest * 8 / 10); - m_funcTable->setColumnWidth(ColExists, rest * 2 / 10); - m_funcTable->setColumnWidth(ColRecurse, RecurseColumnWidth); - m_funcTable->setColumnWidth(ColAction, ActionColumnWidth); - } -} - -void NonLsConvertPage::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - updateTable(); -} - -// 按函数名定位行再编辑: 表格可能在外部文件监控/切页时被全量重建, -// 排队请求中的旧行号会失效指向错误行, 函数名是唯一稳定键 -void NonLsConvertPage::onEditRowByName(const QString &funcName) -{ - const int row = m_funcList.indexOf(funcName); - if (row >= 0) { - onEditRow(row); - } -} - -void NonLsConvertPage::onEditRow(int row) -{ - if (row < 0 || row >= m_funcList.size()) { - return; - } - - QString oldName = m_funcList[row]; - QString newName = oldName; - - while (true) { - QInputDialog dialog(this); - dialog.setWindowTitle("修改函数名"); - dialog.setLabelText("请输入新的函数名:"); - dialog.setTextValue(oldName); - dialog.resize(300, 150); - - if (dialog.exec() != QDialog::Accepted) { - return; - } - - newName = normalizeNonLsName(dialog.textValue()); - - if (newName.isEmpty()) { - QMessageBox::warning(this, "提示", "函数名不能为空!"); - continue; - } - - if (newName == oldName) { - return; - } - - if (m_funcList.contains(newName)) { - QMessageBox::warning(this, "提示", "该函数名已存在,请重新输入!"); - continue; - } - - break; - } - - const int oldRecursion = m_recursionMap.value(oldName, RecursionNo); - m_recursionMap.remove(oldName); - m_recursionMap.insert(newName, oldRecursion); - - m_funcList[row] = newName; - updateTable(); - if (saveToCustNonLsJson(m_funcList)) { - loadFromCustNonLsJson(); - } else { - QMessageBox::warning(this, "保存失败", "函数名已在内存中修改,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后修改将丢失!"); - } - adviseAboutLsNames(this, {newName}); -} - -void NonLsConvertPage::onAddFunction() -{ - QDialog dialog(this); - dialog.setWindowTitle("添加函数"); - dialog.resize(400, 250); - - QVBoxLayout *layout = new QVBoxLayout(&dialog); - - QTextEdit *textEdit = new QTextEdit; - textEdit->setPlaceholderText("每行输入一个函数名(短名,如 AS_xxx)"); - layout->addWidget(textEdit); - - QHBoxLayout *btnLayout = new QHBoxLayout; - QPushButton *okBtn = new QPushButton("确定"); - QPushButton *cancelBtn = new QPushButton("取消"); - btnLayout->addStretch(); - btnLayout->addWidget(okBtn); - btnLayout->addWidget(cancelBtn); - layout->addLayout(btnLayout); - - connect(okBtn, &QPushButton::clicked, &dialog, &QDialog::accept); - connect(cancelBtn, &QPushButton::clicked, &dialog, &QDialog::reject); - - if (dialog.exec() != QDialog::Accepted) { - return; - } - - QString text = textEdit->toPlainText().trimmed(); - if (text.isEmpty()) { - return; - } - - QStringList funcNames = text.split('\n', Qt::SkipEmptyParts); - int addedCount = 0; - int existCount = 0; - QStringList addedNames; - for (const QString &name : funcNames) { - const QString trimmedName = normalizeNonLsName(name); - if (trimmedName.isEmpty()) { - continue; - } - if (m_funcList.contains(trimmedName)) { - existCount++; - continue; - } - m_funcList.append(trimmedName); - m_recursionMap.insert(trimmedName, RecursionNo); // 新增功能默认“否” - addedNames.append(trimmedName); - addedCount++; - } - - if (existCount > 0) { - QMessageBox::information(this, "提示", - QString("成功添加 %1 个函数,%2 个函数已存在").arg(addedCount).arg(existCount)); - } else if (addedCount > 0) { - QMessageBox::information(this, "提示", - QString("成功添加 %1 个函数").arg(addedCount)); - } - - adviseAboutLsNames(this, addedNames); - - updateTable(); - if (addedCount > 0) { - if (!saveToCustNonLsJson(m_funcList)) { - QMessageBox::warning(this, "保存失败", "新增函数已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将丢失!"); - } - } -} - -void NonLsConvertPage::onRemoveFunction() -{ - QModelIndexList selectedIndexes = m_funcTable->selectionModel()->selectedRows(); - if (selectedIndexes.isEmpty()) { - QMessageBox::warning(this, "提示", "请先选中要删除的行!"); - return; - } - - QList rowsToRemove; - for (const QModelIndex &index : selectedIndexes) { - int row = index.row(); - if (!rowsToRemove.contains(row)) { - rowsToRemove.append(row); - } - } - - std::sort(rowsToRemove.begin(), rowsToRemove.end(), std::greater()); - for (int row : rowsToRemove) { - m_recursionMap.remove(m_funcList.at(row)); - m_funcList.removeAt(row); - } - - updateTable(); - if (saveToCustNonLsJson(m_funcList)) { - loadFromCustNonLsJson(); - } else { - QMessageBox::warning(this, "保存失败", "删除操作已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将恢复原清单!"); - } -} - -void NonLsConvertPage::onClearTable() -{ - if (m_funcList.isEmpty()) { - return; - } - - QMessageBox::StandardButton reply; - reply = QMessageBox::question(this, "确认清空", "确定要清空所有函数吗?", - QMessageBox::Yes | QMessageBox::No); - if (reply == QMessageBox::Yes) { - m_funcList.clear(); - m_recursionMap.clear(); - updateTable(); - if (saveToCustNonLsJson(m_funcList)) { - loadFromCustNonLsJson(); - } else { - QMessageBox::warning(this, "保存失败", "清空操作已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将恢复原清单!"); - } - } -} - -void NonLsConvertPage::updateTipStyle() -{ - const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); - const QString textColor = ElaThemeColor(mode, BasicText).name(); if (m_tipLabel) { m_tipLabel->setStyleSheet(QString("color: %1; font-size: 12px;").arg(textColor)); } - if (m_progressLabel) { - // 仅联动颜色: ElaText 保持默认字号(与"全量转码"页一致), 强设 12px 过小(用户反馈) - m_progressLabel->setStyleSheet(QString("color: %1;").arg(textColor)); +} + +int NonLsConvertPage::tableColumnCount() const +{ + return 4; +} + +QStringList NonLsConvertPage::tableHeaderLabels() const +{ + return {"UF20函数名", "是否存在", "是否递归", "操作"}; +} + +int NonLsConvertPage::tableMinimumHeight() const +{ + return 180; +} + +void NonLsConvertPage::applyColumnLayout() +{ + // 4 列按 11:3:2:2 比例分摊、恰好铺满表格(约 61%/17%/11%/11%): + // "是否递归"占 2 份,保证表头四字与"否/是"下拉完整显示不截断; + // 取整余数并入函数名列,确保总宽精确铺满、无横向滚动条 + const int totalWidth = m_funcTable->viewport()->width(); + const int unit = totalWidth / 18; + m_funcTable->setColumnWidth(ColName, totalWidth - unit * 7); + m_funcTable->setColumnWidth(ColExists, unit * 3); + m_funcTable->setColumnWidth(ColRecurse, unit * 2); + m_funcTable->setColumnWidth(ColAction, unit * 2); +} + +QStandardItem *NonLsConvertPage::createNameItem(const QString &funcName) +{ + QStandardItem *nameItem = new QStandardItem(funcName); + // LS_ 开头的函数给出黄色提示(不拦截) + if (funcName.startsWith(Constants::FuncName::LsPrefix)) { + nameItem->setData(QBrush(QColor(196, 126, 10)), Qt::ForegroundRole); + } + return nameItem; +} + +void NonLsConvertPage::attachRowItems(QList &rowItems, const QString &funcName) +{ + Q_UNUSED(funcName) + // “是否递归”以行尾下拉呈现:否=0、是=-1(与 cust_nonls.json convert.dir 值一致) + QStandardItem *recItem = new QStandardItem(""); + recItem->setFlags(recItem->flags() & ~Qt::ItemIsEditable); + recItem->setTextAlignment(Qt::AlignCenter); + rowItems.append(recItem); +} + +void NonLsConvertPage::attachRowWidgets(int row, const QString &funcName) +{ + // “是否递归”下拉控件:否/是,切换即写回内存映射并持久化 + ElaComboBox *recCombo = new ElaComboBox; + recCombo->addItem("否", RecursionNo); + recCombo->addItem("是", RecursionYes); + recCombo->setCurrentIndex( + (m_recursionMap.value(funcName, RecursionNo) == RecursionYes) ? 1 : 0); + connect(recCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, [this, funcName, recCombo](int index) { + const int recursion = (index == 1) ? RecursionYes : RecursionNo; + const int prev = m_recursionMap.value(funcName, RecursionNo); + if (prev == recursion) { + return; + } + m_recursionMap.insert(funcName, recursion); + // 值变化才落盘, 不重建表格(下拉行内数据无变化)。 + // 落盘失败时回滚内存与下拉选中项: 否则界面显示新值而磁盘仍是旧值, + // 重启后配置与界面矛盾 + if (!saveConfig(m_funcList)) { + m_recursionMap.insert(funcName, prev); + recCombo->blockSignals(true); + recCombo->setCurrentIndex(prev == RecursionYes ? 1 : 0); + recCombo->blockSignals(false); + } + }); + m_funcTable->setIndexWidget(m_tableModel->index(row, ColRecurse), recCombo); +} + +QString NonLsConvertPage::normalizeFunctionName(const QString &raw) const +{ + return normalizeNonLsName(raw); +} + +void NonLsConvertPage::onFunctionAdded(const QString &name) +{ + m_recursionMap.insert(name, RecursionNo); // 新增功能默认“否” +} + +void NonLsConvertPage::onFunctionsRemoved(const QStringList &names) +{ + for (const QString &name : names) { + m_recursionMap.remove(name); } } -bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) +void NonLsConvertPage::onFunctionsRenamed(const QString &oldName, const QString &newName) +{ + const int oldRecursion = m_recursionMap.value(oldName, RecursionNo); + m_recursionMap.remove(oldName); + m_recursionMap.insert(newName, oldRecursion); +} + +void NonLsConvertPage::onFunctionListCleared() +{ + m_recursionMap.clear(); +} + +void NonLsConvertPage::adviseAddedFunctions(const QStringList &names) +{ + adviseAboutLsNames(this, names); +} + +bool NonLsConvertPage::saveConfig(const QStringList &funcList) { const QString jsonPath = Constants::custNonLsJson(); @@ -568,7 +235,8 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) } // convert.dir 键值表:{功能名: 0|-1};0=否(不递归)、-1=是(递归),并保留/兜底引擎默认键 "*" - const QJsonObject oldDir = root.value("convert").toObject().value("dir").toObject(); + const QJsonObject oldDir = root.value(Constants::CustKeys::ConvertDir).toObject() + .value(Constants::CustKeys::ConvertDirSub).toObject(); QJsonObject dirObj; for (const QString &name : funcList) { dirObj.insert(name, m_recursionMap.value(name, RecursionNo)); @@ -580,20 +248,24 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) } QJsonArray extArray; - for (const QString &ext : {QStringLiteral(".service_design"), - QStringLiteral(".function_design"), - QStringLiteral(".aservice_design"), - QStringLiteral(".procedure_design"), - QStringLiteral(".afunction_design")}) { + for (const QString &ext : {Constants::DesignExts::Service, + Constants::DesignExts::Function, + Constants::DesignExts::AService, + Constants::DesignExts::Procedure, + Constants::DesignExts::AFunction}) { extArray.append(ext); } QJsonObject convertObj; - convertObj["dir"] = dirObj; - convertObj["ext"] = extArray; - root["convert"] = convertObj; + convertObj[Constants::CustKeys::ConvertDirSub] = dirObj; + convertObj[Constants::CustKeys::ConvertExtSub] = extArray; + root[Constants::CustKeys::ConvertDir] = convertObj; // QSaveFile 原子写: 先写临时文件, commit 成功才替换目标文件, - // 避免写入中途崩溃/断电损坏配置 + // 避免写入中途崩溃/断电损坏配置。 + // 保存窗口: 写入前开启, 窗口期内 QSaveFile 替换目标文件引发的 + // fileChanged/directoryChanged 监控事件直接忽略, 防止自身保存触发重载; + // 失败路径(打开/提交失败)由组件 2s 兜底自动关窗 + m_configMonitor->beginSaveWindow(); QSaveFile file(jsonPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { LogManager::instance()->logWarning( @@ -608,12 +280,12 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) return false; } - // 程序自身写入后更新 mtime,避免触发无谓的重载 - m_lastFileMtime = currentFileMtime(); + // 程序自身写入后更新监控 mtime 基准, 避免触发无谓的重载 + m_configMonitor->endSave(); return true; } -void NonLsConvertPage::loadFromCustNonLsJson() +void NonLsConvertPage::loadConfig() { const QString jsonPath = Constants::custNonLsJson(); if (!QFile::exists(jsonPath)) { @@ -639,7 +311,7 @@ void NonLsConvertPage::loadFromCustNonLsJson() } QJsonObject root = doc.object(); - const QJsonValue dirVal = root["convert"].toObject()["dir"]; + const QJsonValue dirVal = root[Constants::CustKeys::ConvertDir].toObject()[Constants::CustKeys::ConvertDirSub]; QStringList items; m_recursionMap.clear(); @@ -670,142 +342,39 @@ void NonLsConvertPage::loadFromCustNonLsJson() } m_funcList = items; - updateTable(); + refreshTable(); } -void NonLsConvertPage::onStartConvert() +QString NonLsConvertPage::configFilePath() const { - if (m_startBtn->text() == "停止转换") { - m_progressLabel->setText("正在停止转换..."); - emit stopConvert(); - return; - } + return Constants::custNonLsJson(); +} - if (m_funcList.isEmpty()) { - QMessageBox::warning(this, "提示", "请先添加需要转码的函数!"); - return; - } +QString NonLsConvertPage::workbenchTitle() const +{ + return "按需转码"; +} - m_progressBar->setValue(0); - m_progressLabel->setText("正在准备转换..."); +QString NonLsConvertPage::configDisplayName() const +{ + return "cust_nonls.json"; +} - if (!saveToCustNonLsJson(m_funcList)) { - m_progressLabel->setText("配置保存失败"); - return; - } +QString NonLsConvertPage::addDialogTitle() const +{ + return "添加函数"; +} +QString NonLsConvertPage::addDialogPlaceholder() const +{ + return "每行输入一个函数名(短名,如 AS_xxx)"; +} + +bool NonLsConvertPage::prepareStartArguments() +{ bool ok = false; m_preparedArgs = buildArguments(&ok); - if (!ok) { - m_progressLabel->setText("参数生成失败"); - return; - } - - m_progressBar->setValue(20); - m_progressLabel->setText("配置加载成功,开始转换..."); - - emit startConvert(); -} - -void NonLsConvertPage::onConvertStarted() -{ - m_startBtn->setText("停止转换"); - m_startBtn->setStyleSheet("background-color: #e74c3c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); -} - -void NonLsConvertPage::onConvertFinished() -{ - m_progressBar->setValue(100); - m_progressLabel->setText("转换完成"); - m_startBtn->setText("开始转换"); - m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); -} - -void NonLsConvertPage::onConvertFailed(const QString &detail) -{ - // 失败收尾: 进度条保持当前进度(不复位 100%, 与成功完成区分), 文字提示转换失败 - m_progressLabel->setText(detail.isEmpty() ? "转换失败" : detail); - m_startBtn->setText("开始转换"); - m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;"); -} - -void NonLsConvertPage::setTaskBusy(bool busy) -{ - if (!m_startBtn) { - return; - } - m_startBtn->setEnabled(!busy); - if (busy) { - m_progressBar->setValue(0); - m_progressLabel->setText("有其他转换任务进行中,请等待完成..."); - } else { - m_progressLabel->setText("等待开始..."); - } -} - -void NonLsConvertPage::addFunction(const QString &funcName) -{ - if (funcName.isEmpty()) { - return; - } - - const QString name = normalizeNonLsName(funcName); - if (m_funcList.contains(name)) { - QMessageBox::information(nullptr, "提示", QString("功能 [%1] 已在列表中").arg(name)); - return; - } - - m_funcList.append(name); - m_recursionMap.insert(name, RecursionNo); // 新增功能默认“否” - updateTable(); - - if (saveToCustNonLsJson(m_funcList)) { - QMessageBox::information(nullptr, "提示", QString("已将功能 [%1] 添加到\"按需转码\"").arg(name)); - } else { - QMessageBox::warning(nullptr, "警告", "添加到\"按需转码\"成功,但保存到 cust_nonls.json 失败"); - } -} - -QPair NonLsConvertPage::addFunctionsBatch(const QStringList &funcNames, bool notify) -{ - // 批量添加(功能查询页多选双击): 不逐条弹窗; - // notify=true 时结束后一次汇总提示, notify=false 静默(由调用方统一汇总), 返回 {新增数, 跳过数} - QStringList added; - int skipped = 0; - for (const QString &funcName : funcNames) { - if (funcName.isEmpty()) { - continue; - } - const QString name = normalizeNonLsName(funcName); - if (m_funcList.contains(name)) { - ++skipped; - continue; - } - m_funcList.append(name); - m_recursionMap.insert(name, RecursionNo); // 新增功能默认“否” - added.append(name); - } - - if (added.isEmpty()) { - if (notify && skipped > 0) { - QMessageBox::information(nullptr, "提示", - QString("所选 %1 个功能均已在\"按需转码\"列表中").arg(skipped)); - } - return qMakePair(0, skipped); - } - - updateTable(); - if (!saveToCustNonLsJson(m_funcList)) { - QMessageBox::warning(nullptr, "警告", - QString("添加成功,但保存到 cust_nonls.json 失败(%1 个功能)").arg(added.size())); - return qMakePair(-1, -1); - } - if (notify) { - QMessageBox::information(nullptr, "提示", - QString("已将 %1 个功能加入\"按需转码\"").arg(added.size()) + - (skipped > 0 ? QString("(另有 %1 个已存在,已跳过)").arg(skipped) : QString())); - } - return qMakePair(added.size(), skipped); + return ok; } QStringList NonLsConvertPage::buildArguments(bool *ok) @@ -818,64 +387,3 @@ QStringList NonLsConvertPage::buildArguments(bool *ok) // 函数清单与“是否递归”开关由引擎直接读取 config/cust_nonls.json,无需再传 -t/-o 参数 return QStringList() << "-m" << "2"; } - -void NonLsConvertPage::setupConfigWatcher() -{ - const QString configPath = getConfigFilePath(); - - m_configWatcher = new QFileSystemWatcher(this); - - if (QFile::exists(configPath)) { - m_configWatcher->addPath(configPath); - } - - const QString dir = QFileInfo(configPath).absolutePath(); - if (QDir(dir).exists()) { - m_configWatcher->addPath(dir); - } - - connect(m_configWatcher, &QFileSystemWatcher::fileChanged, - this, &NonLsConvertPage::onConfigFileChanged); - connect(m_configWatcher, &QFileSystemWatcher::directoryChanged, - this, &NonLsConvertPage::onConfigDirChanged); -} - -QDateTime NonLsConvertPage::currentFileMtime() const -{ - QFileInfo info(getConfigFilePath()); - return info.exists() ? info.lastModified() : QDateTime(); -} - -void NonLsConvertPage::onConfigFileChanged(const QString &path) -{ - if (m_configWatcher && !m_configWatcher->files().contains(path) - && QFile::exists(path)) { - m_configWatcher->addPath(path); - } - m_reloadTimer->start(); // 防抖 -} - -void NonLsConvertPage::onConfigDirChanged(const QString &dirPath) -{ - Q_UNUSED(dirPath); - const QString configPath = getConfigFilePath(); - if (m_configWatcher && QFile::exists(configPath) - && !m_configWatcher->files().contains(configPath)) { - m_configWatcher->addPath(configPath); - } - m_reloadTimer->start(); // 防抖 -} - -void NonLsConvertPage::onReloadTimeout() -{ - QDateTime now = currentFileMtime(); - if (now.isValid() && now != m_lastFileMtime) { - m_lastFileMtime = now; - loadFromCustNonLsJson(); - } -} - -QString NonLsConvertPage::getConfigFilePath() const -{ - return Constants::custNonLsJson(); -} diff --git a/src/pages/nonlsconvert/nonlsconvertpage.h b/src/pages/nonlsconvert/nonlsconvertpage.h index 512ef8a..44662d5 100644 --- a/src/pages/nonlsconvert/nonlsconvertpage.h +++ b/src/pages/nonlsconvert/nonlsconvertpage.h @@ -1,95 +1,62 @@ #ifndef NONLSCONVERTPAGE_H #define NONLSCONVERTPAGE_H -#include -#include -#include +#include "src/pages/common/convertworkbenchbase.h" #include -#include -#include -#include -#include -#include -#include "ElaTableView.h" -#include "ElaProgressBar.h" -#include "ElaText.h" +#include -class QLabel; +class QVBoxLayout; +class QStandardItem; -// 按需转码工作台(业务转码页 Pivot2) -// 交互与全量转码页(BatchConvertPage)保持一致: -// 函数清单(短名)与“是否递归”开关 -> 持久化到 config/cust_nonls.json -// convert.dir 为 {功能名: 0| -1} 键值表:0=否(不递归)、-1=是(递归),另含引擎默认键 "*" +// 按需转码工作台(业务转码页非 LS pivot,针对非 LS 功能转码) +// 函数清单(短名)与"是否递归"开关持久化到 config/cust_nonls.json, +// convert.dir 为 {功能名: 0| -1} 键值表:0=否(不递归)、-1=是(递归),另含引擎默认键 "*" // 调用契约:仅携带 -m 2(按需转码模式);函数清单与递归开关由引擎直接读取 cust_nonls.json, -// 不再需要 -t/-o 等命令行参数 -class NonLsConvertPage : public QWidget +// 不再需要 -t/-o 等命令行参数 +class NonLsConvertPage : public ConvertWorkbenchBase { Q_OBJECT public: explicit NonLsConvertPage(QWidget *parent = nullptr); - ~NonLsConvertPage(); -signals: - void startConvert(); - void stopConvert(); +protected: + // ---- 界面骨架钩子 ---- + void applyPageLayout(QVBoxLayout *layout) override; + void applyTipStyle(const QString &textColor) override; + int tableColumnCount() const override; + QStringList tableHeaderLabels() const override; + int tableMinimumHeight() const override; + void applyColumnLayout() override; -public slots: - void onProgressUpdate(int progress, const QString &status); - void onConvertStarted(); - void onConvertFinished(); - // 失败收尾: 由 MainWindow 在引擎异常退出或检测到 FAIL 行时调用(见 onPythonRunnerFinished) - void onConvertFailed(const QString &detail); - void addFunction(const QString &funcName); - // 批量添加(功能查询页多选双击): 不逐条弹窗, 结束后一次汇总提示; - // notify=false 为静默模式(不弹提示框), 返回 {新增数, 跳过数}, 保存失败返回 {-1, -1}(已弹警告) - QPair addFunctionsBatch(const QStringList &funcNames, bool notify = true); - // 另一工作台任务运行时由 MainWindow 调用,禁用本页开始按钮 - void setTaskBusy(bool busy); + // ---- 表格行钩子 ---- + QStandardItem *createNameItem(const QString &funcName) override; + void attachRowItems(QList &rowItems, const QString &funcName) override; + void attachRowWidgets(int row, const QString &funcName) override; - int getFunctionCount() const { return m_funcList.size(); } - QStringList getFunctionList() const { return m_funcList; } - // 本次开始转换前已拼装好的引擎参数(由 onStartConvert 生成) - QStringList preparedArguments() const { return m_preparedArgs; } + // ---- 名称清洗/校验/附加动作钩子 ---- + QString normalizeFunctionName(const QString &raw) const override; + void onFunctionAdded(const QString &name) override; + void onFunctionsRemoved(const QStringList &names) override; + void onFunctionsRenamed(const QString &oldName, const QString &newName) override; + void onFunctionListCleared() override; + void adviseAddedFunctions(const QStringList &names) override; -private slots: - void onStartConvert(); - void onAddFunction(); - void onRemoveFunction(); - void onClearTable(); - void onEditRow(int row); - void onEditRowByName(const QString &funcName); - void onConfigFileChanged(const QString &path); - void onConfigDirChanged(const QString &path); - void onReloadTimeout(); - void updateTipStyle(); // 灰字说明/进度标签跟随主题明暗切换 - -private: - void initUI(); - bool saveToCustNonLsJson(const QStringList &funcList); - void loadFromCustNonLsJson(); - bool checkFunctionExists(const QString &funcName); - void updateTable(); - void resizeEvent(QResizeEvent *event) override; - void showEvent(QShowEvent *event) override; - QString getConfigFilePath() const; - void setupConfigWatcher(); - QDateTime currentFileMtime() const; + // ---- 持久化与启动准备钩子 ---- + bool saveConfig(const QStringList &funcList) override; + void loadConfig() override; + QString configFilePath() const override; + QString workbenchTitle() const override; + QString configDisplayName() const override; + QString addDialogTitle() const override; + QString addDialogPlaceholder() const override; + bool prepareStartArguments() override; QStringList buildArguments(bool *ok); - ElaTableView *m_funcTable; - QStandardItemModel *m_tableModel; +private: QLabel *m_tipLabel = nullptr; // 页首灰字说明条(需主题联动) - QStringList m_funcList; // 每个清单功能对应的“是否递归”标志:0=否、-1=是(与 cust_nonls.json convert.dir 值一致) QHash m_recursionMap; - ElaProgressBar *m_progressBar; - ElaText *m_progressLabel; - QPushButton *m_startBtn; - QFileSystemWatcher *m_configWatcher; - QTimer *m_reloadTimer; - QDateTime m_lastFileMtime; - QStringList m_preparedArgs; }; #endif // NONLSCONVERTPAGE_H diff --git a/src/pages/settings/settingspage.cpp b/src/pages/settings/settingspage.cpp index d919d0d..2330288 100644 --- a/src/pages/settings/settingspage.cpp +++ b/src/pages/settings/settingspage.cpp @@ -7,11 +7,6 @@ #include #include #include -#include -#include -#include -#include -#include #include #include "ElaLineEdit.h" #include "ElaComboBox.h" @@ -48,7 +43,8 @@ void SettingsPage::initUI() QHBoxLayout *uft30Layout = new QHBoxLayout; QLabel *uft30Label = new QLabel("UFT30项目环境路径:"); uft30Label->setMinimumWidth(160); - m_uft30PathEdit = new ElaLineEdit; + // 传入this避免ElaLineEdit构造函数setVisible(true)在无父时弹出短暂顶层窗口 + m_uft30PathEdit = new ElaLineEdit(this); ElaPushButton *uft30Btn = new ElaPushButton("浏览..."); connect(uft30Btn, &QPushButton::clicked, this, &SettingsPage::onSelectUFT30Path); uft30Layout->addWidget(uft30Label); @@ -60,7 +56,7 @@ void SettingsPage::initUI() QHBoxLayout *uf20Layout = new QHBoxLayout; QLabel *uf20Label = new QLabel("UF20项目环境路径:"); uf20Label->setMinimumWidth(160); - m_uf20PathEdit = new ElaLineEdit; + m_uf20PathEdit = new ElaLineEdit(this); ElaPushButton *uf20Btn = new ElaPushButton("浏览..."); connect(uf20Btn, &QPushButton::clicked, this, &SettingsPage::onSelectUF20Path); uf20Layout->addWidget(uf20Label); @@ -72,7 +68,7 @@ void SettingsPage::initUI() QHBoxLayout *uf20AccountLayout = new QHBoxLayout; QLabel *uf20AccountLabel = new QLabel("UF20账户项目环境路径:"); uf20AccountLabel->setMinimumWidth(160); - m_uf20AccountPathEdit = new ElaLineEdit; + m_uf20AccountPathEdit = new ElaLineEdit(this); ElaPushButton *uf20AccountBtn = new ElaPushButton("浏览..."); connect(uf20AccountBtn, &QPushButton::clicked, this, &SettingsPage::onSelectUF20AccountPath); uf20AccountLayout->addWidget(uf20AccountLabel); @@ -80,11 +76,23 @@ void SettingsPage::initUI() uf20AccountLayout->addWidget(uf20AccountBtn); envLayout->addLayout(uf20AccountLayout); + // UF20清算项目环境路径(UftdbSett: 清算参数项目目录, 引擎按其生成清算相关元数据) + QHBoxLayout *uf20ClearingLayout = new QHBoxLayout; + QLabel *uf20ClearingLabel = new QLabel("UF20清算项目环境路径:"); + uf20ClearingLabel->setMinimumWidth(160); + m_uf20ClearingPathEdit = new ElaLineEdit(this); + ElaPushButton *uf20ClearingBtn = new ElaPushButton("浏览..."); + connect(uf20ClearingBtn, &QPushButton::clicked, this, &SettingsPage::onSelectUF20ClearingPath); + uf20ClearingLayout->addWidget(uf20ClearingLabel); + uf20ClearingLayout->addWidget(m_uf20ClearingPathEdit); + uf20ClearingLayout->addWidget(uf20ClearingBtn); + envLayout->addLayout(uf20ClearingLayout); + // 转码生成路径 QHBoxLayout *outputLayout = new QHBoxLayout; QLabel *outputLabel = new QLabel("转码生成路径:"); outputLabel->setMinimumWidth(160); - m_outputPathEdit = new ElaLineEdit; + m_outputPathEdit = new ElaLineEdit(this); ElaPushButton *outputBtn = new ElaPushButton("浏览..."); connect(outputBtn, &QPushButton::clicked, this, &SettingsPage::onSelectOutputPath); outputLayout->addWidget(outputLabel); @@ -106,7 +114,7 @@ void SettingsPage::initUI() QLabel *themeLabel = new QLabel("主题模式:"); themeLabel->setMinimumWidth(160); m_themeCombo = new ElaComboBox; - m_themeCombo->addItems({"日间模式", "夜间模式"}); + m_themeCombo->addItems({Constants::AppSettings::ThemeLight, Constants::AppSettings::ThemeDark}); m_themeCombo->setMinimumWidth(150); connect(m_themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &SettingsPage::onThemeChanged); themeLayout->addWidget(themeLabel); @@ -159,199 +167,50 @@ void SettingsPage::loadSettings() { loadFromConfigIni(); - QSettings settings("UFT30", "ChangeCode"); + QSettings settings(Constants::AppSettings::Org, Constants::AppSettings::App); // 默认"夜间模式"与 Ela 默认主题一致, 保证下拉框显示值与界面实际主题相符 - QString theme = settings.value("Theme", "夜间模式").toString(); + QString theme = settings.value(Constants::AppSettings::ThemeKey, + Constants::AppSettings::ThemeDark).toString(); int index = m_themeCombo->findText(theme); if (index >= 0) { m_themeCombo->setCurrentIndex(index); } } -QString SettingsPage::getConfigIniPath() -{ - return Constants::configIni(); -} - -bool SettingsPage::isValidUtf8(const QByteArray &data) -{ - QString test = QString::fromUtf8(data); - return test.toUtf8() == data; -} - void SettingsPage::loadFromConfigIni() { - QString iniPath = getConfigIniPath(); - if (!QFile::exists(iniPath)) { - return; - } + // 复用 ConfigManager 的解析与缓存(旧手写 ini 解析与 ConfigManager 存在编码/正则处理漂移风险): + // main() 已在窗口构造前 loadConfig, 此处直接读内存缓存。路径键在缓存中恒为正斜杠, + // 显示层换回反斜杠与旧实现保持一致 + ConfigManager *configManager = ConfigManager::instance(); + const auto toDisplayPath = [](QString v) { v.replace("/", "\\"); return v; }; - QFile file(iniPath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - - QByteArray data = file.readAll(); - file.close(); - - QString content; - if (isValidUtf8(data)) { - content = QString::fromUtf8(data); - } else { - content = QString::fromLocal8Bit(data); - } - - bool inProjectPath = false; - const QStringList lines = content.split('\n'); - for (const QString &rawLine : lines) { - QString line = rawLine.trimmed(); - if (line.startsWith('[')) { - inProjectPath = (line == "[projectPath]"); - continue; - } - if (!inProjectPath || line.isEmpty() || line.startsWith(';') || line.startsWith('#')) { - continue; - } - - int eqPos = line.indexOf('='); - if (eqPos < 0) continue; - - QString key = line.left(eqPos).trimmed(); - QString value = line.mid(eqPos + 1).trimmed(); - value.replace("/", "\\"); - - if (key == "uft30") m_uft30PathEdit->setText(value); - else if (key == "uf20") m_uf20PathEdit->setText(value); - else if (key == "ufAcct20") m_uf20AccountPathEdit->setText(value); - else if (key == "createPath") m_outputPathEdit->setText(value); - } -} - -void SettingsPage::saveToConfigIni() -{ - QString iniPath = getConfigIniPath(); - - QString content; - bool isUtf8 = true; - if (QFile::exists(iniPath)) { - QFile file(iniPath); - if (file.open(QIODevice::ReadOnly)) { - QByteArray data = file.readAll(); - file.close(); - isUtf8 = isValidUtf8(data); - if (isUtf8) { - content = QString::fromUtf8(data); - } else { - content = QString::fromLocal8Bit(data); - } - } - } - - auto toWinPath = [](const QString &p) -> QString { - return p.trimmed().replace("/", "\\"); - }; - - QString uft30Val = toWinPath(m_uft30PathEdit->text()); - QString uf20Val = toWinPath(m_uf20PathEdit->text()); - QString ufAcct20Val = toWinPath(m_uf20AccountPathEdit->text()); - QString createPathVal = toWinPath(m_outputPathEdit->text()); - - bool inProjectPath = false; - QStringList newLines; - QSet updatedKeys; - - QStringList lines = content.split('\n'); - for (int i = 0; i < lines.size(); ++i) { - QString line = lines[i]; - QString trimmed = line.trimmed(); - - if (trimmed.startsWith('[')) { - inProjectPath = (trimmed == "[projectPath]"); - newLines << line; - continue; - } - - if (trimmed.startsWith("uft30pub=")) { - int eqPos = trimmed.indexOf('='); - QString pathValue = trimmed.mid(eqPos + 1); - int pubIndex = pathValue.indexOf("\\upub"); - if (pubIndex >= 0) { - QString pubSuffix = pathValue.mid(pubIndex); - QString newPath = uft30Val + pubSuffix; - newLines << ("uft30pub=" + newPath); - continue; - } - } - - if (inProjectPath && !trimmed.isEmpty() && !trimmed.startsWith(';') && !trimmed.startsWith('#')) { - int eqPos = trimmed.indexOf('='); - if (eqPos > 0) { - QString key = trimmed.left(eqPos).trimmed(); - if (key == "uft30") { - newLines << ("uft30=" + uft30Val); - updatedKeys.insert(key); - continue; - } else if (key == "uf20") { - newLines << ("uf20=" + uf20Val); - updatedKeys.insert(key); - continue; - } else if (key == "ufAcct20") { - newLines << ("ufAcct20=" + ufAcct20Val); - updatedKeys.insert(key); - continue; - } else if (key == "createPath") { - newLines << ("createPath=" + createPathVal); - updatedKeys.insert(key); - continue; - } - } - } - - newLines << line; - } - - bool hasProjectPath = content.contains("[projectPath]"); - if (!hasProjectPath) { - if (!newLines.isEmpty() && !newLines.last().isEmpty()) { - newLines << ""; - } - newLines << "[projectPath]"; - newLines << ("uft30=" + uft30Val); - newLines << ("uf20=" + uf20Val); - newLines << ("ufAcct20=" + ufAcct20Val); - newLines << ("createPath=" + createPathVal); - } - - // QSaveFile 原子写: 先写临时文件成功后替换, 中断不会把 config.ini 破坏成半截 - QSaveFile outFile(iniPath); - if (!outFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - LogManager::instance()->logError("无法打开 config.ini 进行写入: " + iniPath); - return; - } - QByteArray outData; - if (isUtf8) { - outData = newLines.join("\n").toUtf8(); - } else { - outData = newLines.join("\n").toLocal8Bit(); - } - outFile.write(outData); - if (!outFile.commit()) { - LogManager::instance()->logError( - QString("config.ini 提交失败(磁盘空间/权限?): %1").arg(iniPath)); - } + m_uft30PathEdit->setText(toDisplayPath(configManager->getValue( + Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30))); + m_uf20PathEdit->setText(toDisplayPath(configManager->getValue( + Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUf20))); + m_uf20AccountPathEdit->setText(toDisplayPath(configManager->getValue( + Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUfAcct20))); + m_uf20ClearingPathEdit->setText(toDisplayPath(configManager->getValue( + Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUftdbSett))); + m_outputPathEdit->setText(toDisplayPath(configManager->getValue( + Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpCreatePath))); } void SettingsPage::onSaveSettings() { - QSettings settings("UFT30", "ChangeCode"); - settings.setValue("Theme", m_themeCombo->currentText()); + QSettings settings(Constants::AppSettings::Org, Constants::AppSettings::App); + settings.setValue(Constants::AppSettings::ThemeKey, m_themeCombo->currentText()); ConfigManager* configManager = ConfigManager::instance(); - QString uft30Val = m_uft30PathEdit->text().trimmed().replace("/", "\\"); - QString uf20Val = m_uf20PathEdit->text().trimmed().replace("/", "\\"); - QString ufAcct20Val = m_uf20AccountPathEdit->text().trimmed().replace("/", "\\"); - QString createPathVal = m_outputPathEdit->text().trimmed().replace("/", "\\"); + // 路径分隔符不再在此处转换: ConfigManager::setValue 对路径白名单键统一归一化为正斜杠, + // saveConfig 写盘时再换回反斜杠, 读写往返对称(旧实现在此手动转反斜杠, 缓存分隔符形式与 loadConfig 后不一致) + QString uft30Val = m_uft30PathEdit->text().trimmed(); + QString uf20Val = m_uf20PathEdit->text().trimmed(); + QString ufAcct20Val = m_uf20AccountPathEdit->text().trimmed(); + QString uf20ClearingVal = m_uf20ClearingPathEdit->text().trimmed(); + QString createPathVal = m_outputPathEdit->text().trimmed(); // 输入校验: 逐项检查非空与目录存在性; uft30pub 为派生路径一并探测 QStringList problems; @@ -359,6 +218,7 @@ void SettingsPage::onSaveSettings() {"UFT30项目环境路径", uft30Val}, {"UF20项目环境路径", uf20Val}, {"UF20账户项目环境路径", ufAcct20Val}, + {"UF20清算项目环境路径", uf20ClearingVal}, {"转码生成路径", createPathVal}, }; for (const auto &check : pathChecks) { @@ -370,8 +230,8 @@ void SettingsPage::onSaveSettings() } if (uft30Val.isEmpty()) { problems << "UFT30元数据路径(uft30pub): 依赖 UFT30项目环境路径, 当前无法生成"; - } else if (!QDir(uft30Val + "\\upub\\dev_codes").exists()) { - problems << QString("UFT30元数据路径: %1 不存在").arg(uft30Val + "\\upub\\dev_codes"); + } else if (!QDir(uft30Val + "/upub/dev_codes").exists()) { + problems << QString("UFT30元数据路径: %1 不存在").arg(uft30Val + "/upub/dev_codes"); } if (!problems.isEmpty()) { @@ -385,13 +245,14 @@ void SettingsPage::onSaveSettings() } } - configManager->setValue("projectPath", "uft30", uft30Val); - configManager->setValue("projectPath", "uf20", uf20Val); - configManager->setValue("projectPath", "ufAcct20", ufAcct20Val); - configManager->setValue("projectPath", "createPath", createPathVal); - - QString uft30PubVal = uft30Val + "\\upub\\dev_codes"; - configManager->setValue("projectPath", "uft30pub", uft30PubVal); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30, uft30Val); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUf20, uf20Val); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUfAcct20, ufAcct20Val); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUftdbSett, uf20ClearingVal); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpCreatePath, createPathVal); + + QString uft30PubVal = uft30Val + "/upub/dev_codes"; + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30Pub, uft30PubVal); // 检查落盘结果: 磁盘满/只读/父目录被删时 QSaveFile 打开或 commit 都会失败, // 不检查会让 UI 谎报成功, 用户重启后路径全部丢失 @@ -413,25 +274,38 @@ void SettingsPage::onResetSettings() m_uft30PathEdit->clear(); m_uf20PathEdit->clear(); m_uf20AccountPathEdit->clear(); + m_uf20ClearingPathEdit->clear(); m_outputPathEdit->clear(); m_themeCombo->setCurrentIndex(0); - // setCurrentIndex 不触发 onThemeChanged, 手动调用切回日间主题 - // (UI 显示"日间模式"但 ElaTheme 仍是原值的不一致状态), 并写回 QSettings + // 索引变化时 setCurrentIndex 会经 currentIndexChanged 触发 onThemeChanged; + // 若本就处于日间主题(索引未变)则信号不发射, 手动兜底调用保证主题应用与 + // QSettings 写回必然执行(该函数幂等, 重复调用无害) onThemeChanged(); - saveToConfigIni(); - - // 同步 ConfigManager 内存缓存: 不同步的话其他页面 getValue 仍得重置前的旧路径 + // 同步 ConfigManager 内存缓存后统一落盘: + // 与"保存"走同一条保留式链路(ConfigManager::saveConfig), + // 清掉的 6 个路径键写空值(含 uft30pub 派生键, 与保存侧"恒由 PpUft30 派生"对称: + // 主键已清空而派生键保留旧值, 元数据更新会以陈旧 uft30pub 路径运行), + // 手工注释/键顺序原样保留 ConfigManager *configManager = ConfigManager::instance(); - configManager->setValue("projectPath", "uft30", ""); - configManager->setValue("projectPath", "uf20", ""); - configManager->setValue("projectPath", "ufAcct20", ""); - configManager->setValue("projectPath", "createPath", ""); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30, ""); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUf20, ""); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUfAcct20, ""); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUftdbSett, ""); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpCreatePath, ""); + configManager->setValue(Constants::ConfigKeys::SectionProjectPath, Constants::ConfigKeys::PpUft30Pub, ""); + // 落盘失败必须与"保存"同样弹窗告知: 只记日志会让 UI 谎报重置成功, + // 用户重启后路径全部回滚却毫无提示 + if (!configManager->saveConfig()) { + LogManager::instance()->logWarning("恢复默认后写盘失败, 重启后路径可能回滚"); + QMessageBox::critical(this, "重置失败", + "写入 config.ini 失败(磁盘空间不足、目录只读或被占用?),\n本次重置未持久化,重启后将回滚!"); + } - QSettings settings("UFT30", "ChangeCode"); + QSettings settings(Constants::AppSettings::Org, Constants::AppSettings::App); settings.clear(); // clear 会把主题键一并清掉, 重置后把当前主题(日间)写回, 重启后保持一致 - settings.setValue("Theme", m_themeCombo->currentText()); + settings.setValue(Constants::AppSettings::ThemeKey, m_themeCombo->currentText()); QMessageBox::information(this, "重置成功", "已恢复默认设置!"); } } @@ -460,6 +334,14 @@ void SettingsPage::onSelectUF20AccountPath() } } +void SettingsPage::onSelectUF20ClearingPath() +{ + QString dir = QFileDialog::getExistingDirectory(this, "选择UF20清算项目环境路径", m_uf20ClearingPathEdit->text()); + if (!dir.isEmpty()) { + m_uf20ClearingPathEdit->setText(dir); + } +} + void SettingsPage::onSelectOutputPath() { QString dir = QFileDialog::getExistingDirectory(this, "选择转码生成路径", m_outputPathEdit->text()); @@ -477,6 +359,6 @@ void SettingsPage::onThemeChanged() m_themeCombo->currentIndex() == 1 ? ElaThemeType::Dark : ElaThemeType::Light); // 直接持久化: 原只在"保存设置"时写 QSettings, 只切主题不点保存时 // 重启后主题回退到默认值, 与用户选择不符 - QSettings settings("UFT30", "ChangeCode"); - settings.setValue("Theme", m_themeCombo->currentText()); + QSettings settings(Constants::AppSettings::Org, Constants::AppSettings::App); + settings.setValue(Constants::AppSettings::ThemeKey, m_themeCombo->currentText()); } diff --git a/src/pages/settings/settingspage.h b/src/pages/settings/settingspage.h index 17b4435..0d1e286 100644 --- a/src/pages/settings/settingspage.h +++ b/src/pages/settings/settingspage.h @@ -20,6 +20,7 @@ private slots: void onSelectUFT30Path(); void onSelectUF20Path(); void onSelectUF20AccountPath(); + void onSelectUF20ClearingPath(); void onSelectOutputPath(); void onThemeChanged(); @@ -27,14 +28,12 @@ private: void initUI(); void loadSettings(); void updateThemeStyle(); // 按 Ela 主题(浅色/深色)刷新原生 QLabel/QGroupBox 样式 - void saveToConfigIni(); void loadFromConfigIni(); - QString getConfigIniPath(); - bool isValidUtf8(const QByteArray &data); ElaLineEdit *m_uft30PathEdit; ElaLineEdit *m_uf20PathEdit; ElaLineEdit *m_uf20AccountPathEdit; + ElaLineEdit *m_uf20ClearingPathEdit; // UF20清算项目环境路径(config.ini [projectPath] UftdbSett) ElaLineEdit *m_outputPathEdit; ElaComboBox *m_themeCombo; diff --git a/src/pythonrunner/PythonRunner.cpp b/src/pythonrunner/PythonRunner.cpp index 9d35c6e..4bbf485 100644 --- a/src/pythonrunner/PythonRunner.cpp +++ b/src/pythonrunner/PythonRunner.cpp @@ -1,9 +1,9 @@ #include "PythonRunner.h" +#include "utils/Constants.h" +#include "utils/logmanager.h" PythonRunner::PythonRunner(QObject *parent) : QObject(parent) - , m_runMode(AutoDetect) - , m_pythonPath("python") , m_process(new QProcess(this)) , m_killTimer(nullptr) { @@ -19,13 +19,12 @@ PythonRunner::PythonRunner(QObject *parent) this, &PythonRunner::onReadyReadStandardError); connect(m_process, &QProcess::errorOccurred, this, &PythonRunner::errorOccurred); - connect(m_process, &QProcess::stateChanged, - this, &PythonRunner::stateChanged); // 进程退出即停掉 kill 升级定时器: 原实现 m_killTimer->stop() 只在析构调用, // stop() 后 3s 内若重新 start 新进程, 定时器触发时无法区分新旧进程, 会误杀新任务 connect(m_process, QOverload::of(&QProcess::finished), this, [this](int, QProcess::ExitStatus) { if (m_killTimer) { m_killTimer->stop(); } + if (m_runTimer) { m_runTimer->stop(); } }); } @@ -34,75 +33,62 @@ PythonRunner::~PythonRunner() if (m_killTimer) { m_killTimer->stop(); } + if (m_runTimer) { + m_runTimer->stop(); + } // 析构时不能留孤儿进程: 直接 kill 并有界等待(不再走 stop() 的异步升级, // 对象即将销毁, 定时器回调无处可投递) if (m_process->state() != QProcess::NotRunning) { m_process->kill(); - m_process->waitForFinished(3000); + if (!m_process->waitForFinished(Constants::Timings::ProcessTerminateWaitMs)) { + // 极少数情况(进程 hung 在不可中断的系统调用/Starting 窗口内 kill 无效) + // 下等不到退出: 析构不能无限阻塞, 落错误日志供用户手动清理残留进程 + LogManager::instance()->logError( + QString("PythonRunner 析构: 子进程(pid=%1)在 %2ms 内未退出, 可能残留孤儿进程") + .arg(m_process->processId()) + .arg(Constants::Timings::ProcessTerminateWaitMs)); + } } } -void PythonRunner::setRunMode(RunMode mode) -{ - m_runMode = mode; -} - -PythonRunner::RunMode PythonRunner::runMode() const -{ - return m_runMode; -} - void PythonRunner::setPackagedExePath(const QString &path) { m_packagedExePath = path; } -QString PythonRunner::packagedExePath() const -{ - return m_packagedExePath; -} - -void PythonRunner::setPythonPath(const QString &path) -{ - m_pythonPath = path; -} - -void PythonRunner::setScriptPath(const QString &path) -{ - m_scriptPath = path; -} - -void PythonRunner::setWorkingDirectory(const QString &dir) -{ - m_workingDir = dir; -} - void PythonRunner::setArguments(const QStringList &args) { m_arguments = args; } -PythonRunner::RunMode PythonRunner::detectRunMode() const +void PythonRunner::setRunTimeoutMs(qint64 timeoutMs) { - QFileInfo exeInfo(m_packagedExePath); - if (exeInfo.exists() && exeInfo.isFile()) { - return UsePackagedExe; + m_runTimeoutMs = timeoutMs; +} + +void PythonRunner::armRunTimeout() +{ + if (m_runTimeoutMs <= 0) { + return; // 未启用运行超时 } - - return UsePythonScript; + if (m_runTimer == nullptr) { + m_runTimer = new QTimer(this); + m_runTimer->setSingleShot(true); + connect(m_runTimer, &QTimer::timeout, this, [this]() { + // 整体运行超时: 引擎大概率卡死, 走 stop() 的 terminate→kill 升级链收尾 + LogManager::instance()->logError( + QString("转换引擎运行超过 %1 分钟无结束, 疑似卡死, 自动终止") + .arg(m_runTimeoutMs / 60000)); + stop(); + }); + } + m_runTimer->start(m_runTimeoutMs); } bool PythonRunner::canRun() const { - RunMode effectiveMode = (m_runMode == AutoDetect) ? detectRunMode() : m_runMode; - - if (effectiveMode == UsePackagedExe) { - QFileInfo exeInfo(m_packagedExePath); - return exeInfo.exists() && exeInfo.isFile(); - } else { - QFileInfo scriptInfo(m_scriptPath); - return scriptInfo.exists() && scriptInfo.isFile(); - } + QFileInfo exeInfo(m_packagedExePath); + return exeInfo.exists() && exeInfo.isFile(); } bool PythonRunner::start() @@ -112,8 +98,6 @@ bool PythonRunner::start() return false; } - RunMode effectiveMode = (m_runMode == AutoDetect) ? detectRunMode() : m_runMode; - QString program; QStringList arguments; @@ -123,23 +107,14 @@ bool PythonRunner::start() QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert("PYTHONIOENCODING", "utf-8"); - if (effectiveMode == UsePackagedExe) { - program = m_packagedExePath; - arguments = m_arguments; - QFileInfo exeInfo(m_packagedExePath); - QString exeDir = exeInfo.absolutePath(); - m_process->setWorkingDirectory(exeDir); + program = m_packagedExePath; + arguments = m_arguments; + QFileInfo exeInfo(m_packagedExePath); + QString exeDir = exeInfo.absolutePath(); + m_process->setWorkingDirectory(exeDir); - env.insert("UF2TOUFT3_HOME", exeDir); - m_process->setProcessEnvironment(env); - } else { - program = m_pythonPath; - arguments << m_scriptPath << m_arguments; - if (!m_workingDir.isEmpty()) { - m_process->setWorkingDirectory(m_workingDir); - } - m_process->setProcessEnvironment(env); - } + env.insert("UF2TOUFT3_HOME", exeDir); + m_process->setProcessEnvironment(env); m_process->start(program, arguments); @@ -154,8 +129,8 @@ bool PythonRunner::start() || m_process->error() == QProcess::FailedToStart) { return false; } - // 启动流程已受理: 结果由 started / finished 信号异步上报。 - // runSync 同步语义不受影响: 同步失败时 state 为 NotRunning, waitForFinished 立即返回 false + // 启动流程已受理: 结果由 started / finished 信号异步上报 + armRunTimeout(); return true; } @@ -175,7 +150,7 @@ void PythonRunner::stop() m_killTimer->setSingleShot(true); connect(m_killTimer, &QTimer::timeout, this, &PythonRunner::onKillTimeout); } - m_killTimer->start(3000); + m_killTimer->start(Constants::Timings::ProcessTerminateWaitMs); } void PythonRunner::onKillTimeout() @@ -187,28 +162,11 @@ void PythonRunner::onKillTimeout() bool PythonRunner::isRunning() const { - return m_process->state() == QProcess::Running; -} - -bool PythonRunner::isActive() const -{ + // 宽判定(含 Starting/Stopping): start() 异步化后启动窗口期(杀软扫描可达 3s) + // 窄判定(只查 Running)返回 false, 会让防重入与停止判断静默失效 return m_process->state() != QProcess::NotRunning; } -bool PythonRunner::runSync(int timeout) -{ - if (!start()) { - return false; - } - if (!m_process->waitForFinished(timeout)) { - // 超时不能把仍在运行的进程留在后台: 终止后返回 false(调用方视为执行失败) - m_process->kill(); - m_process->waitForFinished(); - return false; - } - return m_process->exitStatus() == QProcess::NormalExit && m_process->exitCode() == 0; -} - void PythonRunner::onReadyReadStandardOutput() { decodeAndEmit(m_process->readAllStandardOutput(), m_stdoutPending, true); diff --git a/src/pythonrunner/PythonRunner.h b/src/pythonrunner/PythonRunner.h index 05de59c..a30461c 100644 --- a/src/pythonrunner/PythonRunner.h +++ b/src/pythonrunner/PythonRunner.h @@ -14,34 +14,13 @@ class PythonRunner : public QObject Q_OBJECT public: - // 运行模式 - enum RunMode { - UsePackagedExe, // 使用打包好的 exe 文件 - UsePythonScript, // 使用 Python 脚本 + 解释器 - AutoDetect // 自动检测,优先使用打包好的 - }; - explicit PythonRunner(QObject *parent = nullptr); ~PythonRunner(); - // 设置运行模式 - void setRunMode(RunMode mode); - RunMode runMode() const; - // 设置打包好的可执行文件路径 void setPackagedExePath(const QString &path); - QString packagedExePath() const; - // 设置 Python 解释器路径 - void setPythonPath(const QString &path); - - // 设置要运行的脚本路径 - void setScriptPath(const QString &path); - - // 设置工作目录 - void setWorkingDirectory(const QString &dir); - - // 设置传递给脚本的参数 + // 设置传递给引擎的参数 void setArguments(const QStringList &args); // 检查是否可以运行 @@ -53,14 +32,13 @@ public: // 停止执行 void stop(); - // 检查是否正在运行 - bool isRunning() const; - // 含 Starting/Stopping 的宽判定: start() 已异步化, 启动窗口期(杀软扫描可达 3s) - // isRunning() 仍为 false, 停止/防重入必须用本判定否则点击静默无效 - bool isActive() const; + // 设置整体运行超时(毫秒), <=0 禁用: 超时自动走 stop() 的 terminate→kill 升级链, + // 防止引擎卡死后任务无限挂起(须在 start() 前设置) + void setRunTimeoutMs(qint64 timeoutMs); - // 同步执行 - bool runSync(int timeout = 300000); + // 是否正在运行。含 Starting/Stopping 的宽判定: start() 已异步化, 启动窗口期 + // (杀软扫描可达 3s)窄判定返回 false, 会让防重入与停止判断静默失效 + bool isRunning() const; signals: void started(); @@ -68,7 +46,6 @@ signals: void standardOutput(const QString &output); void standardError(const QString &error); void errorOccurred(QProcess::ProcessError error); - void stateChanged(QProcess::ProcessState state); private slots: void onReadyReadStandardOutput(); @@ -78,19 +55,16 @@ private slots: void onKillTimeout(); // stop() 后延时升级 kill 的回调 private: - RunMode m_runMode; QString m_packagedExePath; - QString m_pythonPath; - QString m_scriptPath; - QString m_workingDir; QStringList m_arguments; QProcess *m_process; QTimer *m_killTimer; // terminate 后延时升级 kill 的单发定时器 + QTimer *m_runTimer = nullptr; // 整体运行超时的单发定时器(超时走 stop() 升级链) + qint64 m_runTimeoutMs = 0; // <=0 禁用运行超时(默认) QByteArray m_stdoutPending; // 跨 readyRead 分块的不完整 UTF-8 序列 QByteArray m_stderrPending; - // 自动检测可用的运行方式 - RunMode detectRunMode() const; + void armRunTimeout(); // 启动受理成功后武装运行超时定时器 void decodeAndEmit(const QByteArray& chunk, QByteArray& pending, bool isStdout); void flushPendingOutput(); }; diff --git a/src/utils/Constants.h b/src/utils/Constants.h index 8e90232..6bf2f4e 100644 --- a/src/utils/Constants.h +++ b/src/utils/Constants.h @@ -3,6 +3,7 @@ #include #include +#include // ============================================================================ // 项目公共常量 — 所有 URL、路径、文件名等配置集中于此,修改只需改一处 @@ -17,6 +18,17 @@ namespace App { constexpr char Version[] = "1.6.0"; } +// --------------------------------------------------------------------------- +// QSettings 持久化(main.cpp 主题预应用 + 设置页读写共用, 拼错会静默丢配置) +// --------------------------------------------------------------------------- +namespace AppSettings { + inline const QString Org = "UFT30"; // QSettings 组织名 + inline const QString App = "ChangeCode"; // QSettings 应用名 + inline const QString ThemeKey = "Theme"; // 主题模式键 + inline const QString ThemeLight = "日间模式"; // 主题值: 日间 + inline const QString ThemeDark = "夜间模式"; // 主题值: 夜间(默认, 与 Ela 默认一致) +} + // --------------------------------------------------------------------------- // URL 常量 // --------------------------------------------------------------------------- @@ -56,18 +68,53 @@ namespace ToolFiles { inline const QString NonLsRunList = "config/run_nonls_list.txt"; // 大批量时生成的 @列表文件 } +// --------------------------------------------------------------------------- +// cust.json 顶层键名(散落各页的硬编码字符串集中于此,改键名只动一处) +// --------------------------------------------------------------------------- +namespace CustKeys { + inline const QString ConvertDir = "convert"; // 全量/按需转码清单(其下含 dir/ext 子键) + inline const QString ConvertDirSub = "dir"; // ConvertDir 下的目录清单子键 + inline const QString ConvertExtSub = "ext"; // ConvertDir 下的扩展名清单子键 + inline const QString TableCompare = "tableCompareUf2_Uf3"; // 表替换映射(UF20→UFT3) + inline const QString FunctionReplace = "functionReplace"; // 函数替换映射(UF20→UFT3) + inline const QString NoChangeFullName = "noChangeFullNameCode"; // 不转码功能函数清单(字符串数组) +} + +// --------------------------------------------------------------------------- +// config.ini 的 section/键名(ConfigManager 白名单 + 设置页 + 元数据处理共用, +// 各处清单必须同源, 拼错会静默落到默认值) +// --------------------------------------------------------------------------- +namespace ConfigKeys { + inline const QString SectionProjectPath = "projectPath"; // 项目环境路径节 + inline const QString PpUft30 = "uft30"; // UFT30 项目环境路径 + inline const QString PpUf20 = "uf20"; // UF20 项目环境路径 + inline const QString PpUfAcct20 = "ufAcct20"; // UF20 账户项目环境路径 + inline const QString PpUftdbSett = "UftdbSett"; // UF20 清算参数项目目录 + inline const QString PpCreatePath = "createPath"; // 转码生成路径 + inline const QString PpUft30Pub = "uft30pub"; // UFT30 元数据路径(由 uft30 派生) + inline const QString PpUft30Cbptrans = "uft30cbptrans"; // UFT30 转码公共目录(外部维护) + inline const QString SectionTestConfig = "testConfig"; // 测试配置节(supportUftdbSett/moduleGenerationCheck 共用) + inline const QString SupportUftdbSett = "supportUftdbSett"; // UF20 是否支持清算参数目录(0/1) + inline const QString ModuleGenerationCheck = "moduleGenerationCheck"; // 模块生成检查开关(0/1) +} + // --------------------------------------------------------------------------- // Cache 相关 // 缓存库采用双库 + 键值表结构(对齐 Python src/store/schema.py), // C++ 直接解析业务工程源文件并写库(方向 A), 表结构统一为: // (name TEXT PRIMARY KEY, data TEXT), data 为 JSON 序列化字符串。 -// 另含 _meta 表(dataset TEXT PRIMARY KEY, row_count INTEGER, src_mtime REAL)。 +// 另含 _meta 表(dataset TEXT PRIMARY KEY, row_count INTEGER, src_mtime REAL, +// src_mtimes TEXT 可空 = 逐目录 mtime JSON 数组, 多源数据集逐目录比较防 mtime 相加抵消)。 // --------------------------------------------------------------------------- namespace Cache { // 双库文件(置于 cacheDir() 下) inline const QString Uf20DbFile = "uf20_cache.db"; inline const QString Uft3DbFile = "uft3_cache.db"; + // 双库 dataset 标识(DataCache 各 API 的 db 选择参数, 拼错会静默建错库) + inline const QString Uf20DbName = "uf20"; + inline const QString Uft3DbName = "uft3"; + // uf20 库表名 inline const QString TblUf2Interface = "uf2_interface"; inline const QString TblUf2Table = "uf2_table"; @@ -89,6 +136,11 @@ namespace Cache { // 功能查询用的接口表(本工具只用这两张表) inline const QString Uf20InterfaceTable = TblUf2Interface; inline const QString Uft3InterfaceTable = TblUft3Interface; + + // 行为参数(原 datacache.cpp 魔法数字收编) + inline const int BusyTimeoutMs = 30000; // SQLite busy_timeout(后台写与主线程读并发容忍度) + inline const qlonglong DefaultPageSize = 4096; // page_size 查询失败时的兜底页大小 + inline const qlonglong VacuumThresholdBytes = 64 * 1024 * 1024; // 空闲页字节数超此值触发一次 VACUUM 回收 } // --------------------------------------------------------------------------- @@ -107,6 +159,79 @@ namespace MetaPaths { inline const QString Uf2MenuFunc = "/公共资源/menuandfunction.xml"; // UF2 菜单与功能文件 // 文件扩展名 inline const QString Uft3StructExt = ".uftstructure"; // UFT3 表结构文件扩展名 + + // 递归扫描匹配/跳过的文件名(接口扫描 completeNameList 与模块、表扫描共用) + inline const QString ModuleXml = "module.xml"; + inline const QString Uf2ProjectFile = ".project"; // UF2 接口扫描跳过的工程文件 + inline const QString Uft3ProjectFile = "project.xml"; // UFT3 接口扫描跳过的工程文件 + inline const QString ConflictWlua = "冲突检测.wlua"; // 清算冲突检测脚本 +} + +// --------------------------------------------------------------------------- +// 设计器文件扩展名(cust_nonls.json 的 ext 清单与清单项清洗/拼接共用) +// --------------------------------------------------------------------------- +namespace DesignExts { + inline const QString Service = ".service_design"; + inline const QString Function = ".function_design"; + inline const QString AService = ".aservice_design"; + inline const QString Procedure = ".procedure_design"; + inline const QString AFunction = ".afunction_design"; +} + +// --------------------------------------------------------------------------- +// 功能查询字段选择器(DataCache 搜索 API 的 field 参数;传错字符串会静默 +// 落入"任一字段匹配"分支且不报错,故集中于此) +// --------------------------------------------------------------------------- +namespace FunctionSearch { + inline const QString FieldCName = "cname"; // 函数中文名 + inline const QString FieldFunctionNo = "function_no"; // 功能号 + inline const QString FieldEName = "eName"; // 函数英文名 +} + +// --------------------------------------------------------------------------- +// 函数名前缀约定(.uftservice 调用格式 [LS_xxx]/[LF_xxx]/[AS_xxx]/[AF_xxx]); +// 前缀同时决定转码分流:LS_ 系 → 全量转码,其余 → 按需转码。 +// batchconvert/nonlsconvert/functionsearch/callchainanalyzer 多处引用,拼错会静默漏判 +// --------------------------------------------------------------------------- +namespace FuncName { + inline const QString LsPrefix = "LS_"; // LS_ 开头 → 全量转码 + inline const QString LfPrefix = "LF_"; // LS_ 系配套函数 + inline const QString AsPrefix = "AS_"; // 非 LS_ 系 + inline const QString AfPrefix = "AF_"; // 非 LS_ 系 +} + +// --------------------------------------------------------------------------- +// 延时毫秒常量(同值不同义时以命名区分,勿混用) +// --------------------------------------------------------------------------- +namespace Timings { + // 进程 terminate/kill 后的有界等待(含 stop() 的升级 kill 窗口与析构等待; + // 窗口语义 = 杀软扫描 exe 可达的启动延迟) + inline const int ProcessTerminateWaitMs = 3000; + inline const int UpdateCheckDelayMs = 3000; // 启动后延迟检查版本更新 + inline const int JsonDebounceMs = 300; // JSON 配置外部修改防抖 + inline const int JsonSaveWindowResetMs = 2000; // 保存窗口异常路径的自动复位兜底 + // 转换引擎整体运行超时: 超时自动走 stop() 升级链终止疑似卡死的引擎, + // 防止任务无限挂起(全量大工程正常耗时可观的场景下取宽裕值) + inline const int CacheWarmupWaitMs = 5000; // 功能缓存后台预热的主线程有界等待上限(超限转同步重建) + inline const qint64 ConvertRunTimeoutMs = 2 * 60 * 60 * 1000; // 2 小时 +} + +// --------------------------------------------------------------------------- +// 调用链树节点状态串(callchainanalyzer 生产 + codesearchpage 消费, +// 生产/消费两侧必须同源, 拼错会静默落入未知状态分支) +// --------------------------------------------------------------------------- +namespace CallTree { + inline const QString StateOk = "ok"; // 查找成功 + inline const QString StateNotFound = "notfound"; // 根节点未找到 + inline const QString StateCycle = "cycle"; // 循环调用 + inline const QString StateDepthLimit = "depthlimit"; // 达到深度上限 + inline const QString StateSupported = "supported"; // 中间节点受支持(仅上溯树) + // 索引时排除的目录段(框架/平台目录, 体量大、无需纳入调用链分析; + // isExcludedDir 按相对 rootPath 的路径段精确匹配, 防止根目录误排除) + inline const QStringList ExcludedDirNames = { "toolset", "ldp_platform" }; + // 反向链枚举硬上限: 菱形汇聚/大环时 per-path 枚举指数爆炸, + // 保证 UI 线程计算有上界, 超出部分以 depthlimit 单节点收尾 + inline constexpr int MaxCallerChains = 200; } // --------------------------------------------------------------------------- @@ -117,8 +242,6 @@ inline QString toolExe() { return toolDir() + "/" + ToolFiles::Exe; } inline QString configIni() { return toolDir() + "/" + ToolFiles::ConfigIni; } inline QString custJson() { return toolDir() + "/" + ToolFiles::CustJson; } inline QString custNonLsJson() { return toolDir() + "/" + ToolFiles::CustNonLsJson; } -inline QString nonLsRunListFile() { return toolDir() + "/" + ToolFiles::NonLsRunList; } -inline QString logDir() { return QCoreApplication::applicationDirPath() + "/" + Paths::LogDir; } inline QString cacheDir() { return toolDir() + "/" + Paths::CacheDir; } // bin/uf2touft3/cache inline QString uf20CacheDb() { return cacheDir() + "/" + Cache::Uf20DbFile; } inline QString uft3CacheDb() { return cacheDir() + "/" + Cache::Uft3DbFile; } @@ -130,6 +253,7 @@ namespace CodeSearch { constexpr char ChangeCodeIni[] = "change_code.ini"; // 路径持久化配置文件名 constexpr char ConvertPathKey[] = "codeSearch/convertPath"; // QSettings 键:转码业务路径 constexpr char SearchPathKey[] = "codeSearch/searchPath"; // QSettings 键:非转码业务路径 + constexpr char ChainFileName[] = "_call_chain.txt"; // 调用链路导出文件名 } inline QString changeCodeDir() { return toolDir() + "/" + Paths::ChangeCodeDir; } // 默认代码检索根目录 diff --git a/src/utils/business_strategy.cpp b/src/utils/business_strategy.cpp deleted file mode 100644 index f5e5fcc..0000000 --- a/src/utils/business_strategy.cpp +++ /dev/null @@ -1,73 +0,0 @@ -#include "business_strategy.h" -#include "uf2configreader.h" -#include "uft3configreader.h" - -namespace { - -// UF2(UF20) 策略:对接 UF2ConfigReader。 -class Uf2BusinessStrategy : public IBusinessStrategy { -public: - BusinessVersion version() const override { return BusinessVersion::UF2; } - QString taskName() const override { return QStringLiteral("UF20"); } - - bool reloadConfig() const override { - return UF2ConfigReader::reloadUF20Config(); - } - bool checkFunctionExists(const QString& funcName) const override { - return UF2ConfigReader::checkFuncExistsStatic(funcName); - } - QSet getAllCnames() const override { - return UF2ConfigReader::getAllCnamesStatic(); - } - void clearCnameCache() const override { - UF2ConfigReader::clearCnameCache(); - } -}; - -// UFT3(UFT30) 策略:对接 UFT3ConfigReader。 -class Uft3BusinessStrategy : public IBusinessStrategy { -public: - BusinessVersion version() const override { return BusinessVersion::UFT3; } - QString taskName() const override { return QStringLiteral("UFT3"); } - - bool reloadConfig() const override { - return UFT3ConfigReader::reloadUFT3Config(); - } - bool checkFunctionExists(const QString& funcName) const override { - return UFT3ConfigReader::checkFuncExistsStatic(funcName); - } - QSet getAllCnames() const override { - return UFT3ConfigReader::getAllCnamesStatic(); - } - void clearCnameCache() const override { - UFT3ConfigReader::clearCnameCache(); - } -}; - -} // namespace - -std::unique_ptr createBusinessStrategy(BusinessVersion version) -{ - switch (version) { - case BusinessVersion::UF2: - return std::unique_ptr(new Uf2BusinessStrategy()); - case BusinessVersion::UFT3: - default: - return std::unique_ptr(new Uft3BusinessStrategy()); - } -} - -BusinessVersion businessVersionFromTaskName(const QString& taskName) -{ - // 兼容 "UF20" 任务名与 "uf2" 简写,其余归为 UFT3。 - if (taskName.compare(QStringLiteral("UF20"), Qt::CaseInsensitive) == 0 - || taskName.compare(QStringLiteral("UF2"), Qt::CaseInsensitive) == 0) { - return BusinessVersion::UF2; - } - return BusinessVersion::UFT3; -} - -std::unique_ptr createBusinessStrategy(const QString& taskName) -{ - return createBusinessStrategy(businessVersionFromTaskName(taskName)); -} diff --git a/src/utils/business_strategy.h b/src/utils/business_strategy.h deleted file mode 100644 index 7b72a78..0000000 --- a/src/utils/business_strategy.h +++ /dev/null @@ -1,47 +0,0 @@ -#ifndef BUSINESS_STRATEGY_H -#define BUSINESS_STRATEGY_H - -#include -#include -#include - -// 业务版本:集中表达 UF2(UF20) 与 UFT3(UFT30) 两个业务版本。 -// 取代散落各处的 taskName == "UF20" / 字符串判断 与成对 if/else 分支。 -enum class BusinessVersion { - UF2, - UFT3 -}; - -// 业务策略接口:把「按业务版本走不同实现」的差异收敛到一族策略对象里。 -// 调用方只面向 IBusinessStrategy 编程,具体版本由工厂选择注入。 -class IBusinessStrategy { -public: - virtual ~IBusinessStrategy() = default; - - // 版本标识与展示用任务名(如 "UF20" / "UFT3") - virtual BusinessVersion version() const = 0; - virtual QString taskName() const = 0; - - // 重新加载该版本配置(对接对应 ConfigReader 的 reload) - virtual bool reloadConfig() const = 0; - - // 函数(接口)是否存在 - virtual bool checkFunctionExists(const QString& funcName) const = 0; - - // 读取该版本全部 cname - virtual QSet getAllCnames() const = 0; - - // 清空该版本 cname 缓存 - virtual void clearCnameCache() const = 0; -}; - -// 工厂:按业务版本创建对应策略。 -std::unique_ptr createBusinessStrategy(BusinessVersion version); - -// 便捷:从任务名/字符串推断业务版本("UF20"/"uf2" -> UF2,其余 -> UFT3)。 -BusinessVersion businessVersionFromTaskName(const QString& taskName); - -// 便捷:直接按任务名创建策略。 -std::unique_ptr createBusinessStrategy(const QString& taskName); - -#endif // BUSINESS_STRATEGY_H diff --git a/src/utils/callchainanalyzer.cpp b/src/utils/callchainanalyzer.cpp index db5ceea..2e54eab 100644 --- a/src/utils/callchainanalyzer.cpp +++ b/src/utils/callchainanalyzer.cpp @@ -1,4 +1,5 @@ #include "callchainanalyzer.h" +#include "utils/Constants.h" #include #include @@ -39,6 +40,21 @@ static bool shouldPrefer(const CallChainAnalyzer::FunctionInfo *existing, return false; // 同优先级,保留先入者 } +// 同名冲突时优先保留“真正的实现”(判定见 shouldPrefer): buildIndex 与 loadFromCache 共用, +// 原两处分别为 lambda 与内联展开, 共三份重复逻辑收敛于此 +static void insertPrefer(QHash> &idx, + const QString &key, + const QSharedPointer &info) +{ + auto it = idx.find(key); + if (it == idx.end()) { + idx.insert(key, info); + } else if (shouldPrefer(it.value().data(), info.data())) { + it.value() = info; + } +} + + // 索引时排除的目录(框架/平台目录,无需纳入调用链分析,且体量大、扫描慢)。 // 只对相对 rootPath 的路径段匹配: 原实现对完整路径拆段匹配, 工程 rootPath 本身 // 恰为 .../toolset 时会把整个根目录误排除, 导致索引恒为空 @@ -51,13 +67,22 @@ static bool isExcludedDir(const QString &rootPath, const QString &filePath) const QStringList parts = QDir::fromNativeSeparators(rel) .toLower().split('/', Qt::SkipEmptyParts); for (const QString &part : parts) { - if (part == "toolset" || part == "ldp_platform") { + if (Constants::CallTree::ExcludedDirNames.contains(part)) { return true; } } return false; } +// 索引扫描与指纹统计共用的五种格式过滤(文件级唯一份): +// 两处口径必须一致, 否则新增格式只改一处时指纹与索引范围失配, +// 缓存每次启动都判定失效 → 每次全量重扫 +static const QStringList kScanExtensions = { + "*.uftfunction", "*.uftservice", + "*.uftatomfunction", "*.uftatomservice", + "*.extinterface" +}; + void CallChainAnalyzer::setRootPath(const QString &path) { m_rootPath = path; @@ -73,16 +98,11 @@ bool CallChainAnalyzer::isIndexed() const return m_indexed; } -int CallChainAnalyzer::functionCount() const -{ - return m_chineseNameIndex.size(); -} - bool CallChainAnalyzer::buildIndex(QString *errorMessage) { m_chineseNameIndex.clear(); - m_fullNameIndex.clear(); m_canonicalNameIndex.clear(); + m_allInfos.clear(); m_indexed = false; QDir rootDir(m_rootPath); @@ -93,24 +113,11 @@ bool CallChainAnalyzer::buildIndex(QString *errorMessage) return false; } - // 同名冲突时优先保留“真正的实现”而非外部接口声明: - // 外部接口(.extinterface)通常无 ,若同名覆盖实现会让调用链路在叶子处断掉。 - auto insertPrefer = [&](QHash> &idx, - const QString &key, QSharedPointer info) { - auto it = idx.find(key); - if (it == idx.end()) { - idx.insert(key, info); - } else if (shouldPrefer(it.value().data(), info.data())) { - it.value() = info; - } - }; - - // 扫描 .uftfunction、.uftservice、.uftatomfunction、.uftatomservice、.extinterface 五种格式 - static const QStringList kScanExtensions = { - "*.uftfunction", "*.uftservice", - "*.uftatomfunction", "*.uftatomservice", - "*.extinterface" - }; + // 重置指纹统计(仅本次遍历有效, 详见 computeFingerprint 注释) + m_fpCount = 0; + m_fpTotalSize = 0; + m_fpLatestMtime = 0; + m_fpStatsValid = false; int count = 0; // 单遍 QDirIterator 整体名字过滤: 原按 5 种扩展名循环 5 遍全树遍历, @@ -121,6 +128,17 @@ bool CallChainAnalyzer::buildIndex(QString *errorMessage) if (isExcludedDir(m_rootPath, filePath)) { continue; } + // 顺带统计指纹: count/totalSize/latestMtime 口径与 computeFingerprint + // 全树扫描完全一致(统计所有非排除的匹配文件, 与解析成败无关)。 + // buildIndex 在后台线程完成后主线程紧随调用 saveToCache → computeFingerprint, + // 复用本次统计可省去主线程 ~2s 的重复全树 stat(QDirIterator 已缓存 file info, 统计零额外开销) + const QFileInfo fi = it.fileInfo(); + ++m_fpCount; + m_fpTotalSize += fi.size(); + const qint64 mtime = fi.lastModified().toSecsSinceEpoch(); + if (mtime > m_fpLatestMtime) { + m_fpLatestMtime = mtime; + } QSharedPointer info = parseFile(filePath); if (!info) { // 解析失败(XML 损坏/格式不符)静默缺席会让调用链在叶子处断掉且无排查线索 @@ -137,18 +155,18 @@ bool CallChainAnalyzer::buildIndex(QString *errorMessage) // 按 chineseName 索引 insertPrefer(m_chineseNameIndex, info->chineseName, info); - // 按 objectId-chineseName 索引 - if (!info->objectId.isEmpty()) { - insertPrefer(m_fullNameIndex, info->objectId + "-" + info->chineseName, info); - } // 按文件 basename(不含扩展名)索引,方便从 cust.json 名称匹配 if (!info->canonicalName.isEmpty()) { insertPrefer(m_canonicalNameIndex, info->canonicalName, info); } + // 登记全量集合(saveToCache 导出底座; 同名落选变体也保留, + // 保证重启后 loadFromCache 重建的索引与内存一致) + m_allInfos.append(info); ++count; } + m_fpStatsValid = true; // 本次遍历统计完成, computeFingerprint 可复用 m_indexed = true; rebuildReverseIndex(); if (errorMessage) { @@ -157,15 +175,16 @@ bool CallChainAnalyzer::buildIndex(QString *errorMessage) return true; } -QString CallChainAnalyzer::computeFingerprint(const QString &rootPath) +QString CallChainAnalyzer::computeFingerprint(const QString &rootPath) const { // 目录内容指纹:文件数量 + 总字节数 + 最新修改时间,用于判断缓存是否失效。 // 仅读取元数据,不解析内容,开销远小于重新构建索引。 - static const QStringList kScanExtensions = { - "*.uftfunction", "*.uftservice", - "*.uftatomfunction", "*.uftatomservice", - "*.extinterface" - }; + // 优先复用 buildIndex 遍历时顺带统计的数据(紧随 buildIndex 成功之后的 + // saveToCache 属此场景), 省去主线程对同一棵树的重复全树 stat(~2s)。 + // 未命中(如 loadFromCache 失效路径: 索引尚未重建)才全树扫描。 + if (m_fpStatsValid && m_rootPath == rootPath) { + return QString("%1:%2:%3").arg(m_fpCount).arg(m_fpTotalSize).arg(m_fpLatestMtime); + } qint64 totalSize = 0; qint64 latestMtime = 0; @@ -190,12 +209,16 @@ QString CallChainAnalyzer::computeFingerprint(const QString &rootPath) QString CallChainAnalyzer::cacheFilePathFor(const QString &rootPath) { // 缓存目录:程序目录(bin)下的 cache 子目录 - const QString dir = QDir(QCoreApplication::applicationDirPath()).filePath("cache"); - // 以根路径最后一个目录名作为缓存文件名,便于识别(如 A/B/C/D -> D.json)。 - // 不同路径同名目录的冲突由 loadFromCache 中的 rootPath 校验兜底(失效则重建)。 + const QString dir = QDir(QCoreApplication::applicationDirPath()).filePath(Constants::Paths::CacheDir); + // 缓存文件名 = 根路径最后一级目录名 + 全路径短哈希(如 A/B/C/D -> D_1a2b3c4d.json): + // 目录名保留人工可识别性; 哈希区分不同路径下的同名目录, 避免两个不同工程共用 + // 同一缓存文件后仅靠 loadFromCache 的 rootPath 校验兜底、切换工程时整树重建。 const QString base = QDir(rootPath).dirName(); const QString safe = base.isEmpty() ? QStringLiteral("index") : base; - return QDir(dir).filePath(safe + ".json"); + const QString norm = QDir::fromNativeSeparators(QDir(rootPath).absolutePath()).toLower(); + const QString hash = QString::fromLatin1( + QCryptographicHash::hash(norm.toUtf8(), QCryptographicHash::Md5).toHex().left(8)); + return QDir(dir).filePath(safe + "_" + hash + ".json"); } QString CallChainAnalyzer::metaFilePathFor(const QString &rootPath) @@ -263,33 +286,24 @@ void CallChainAnalyzer::saveToCache() const const QString cacheFile = cacheFilePathFor(m_rootPath); QDir().mkpath(QFileInfo(cacheFile).absolutePath()); - // 合并三个索引的全部条目(按对象指针去重): 只遍历 chineseNameIndex 会把 - // shouldPrefer 落选的同名变体丢掉(该变体可能仍挂在 fullName/canonicalName - // 索引下), 重启后 loadFromCache 重建的索引与内存不一致 - QSet seenInfos; + // 遍历全量函数集合直接导出: 索引哈希因 shouldPrefer 同名落选会缺席部分变体, + // 全量集合是两条构建路径的共同底座, 是单一导出事实来源(天然无重复) QJsonArray funcs; - for (const auto *idx : { &m_chineseNameIndex, &m_fullNameIndex, &m_canonicalNameIndex }) { - for (auto it = idx->constBegin(); it != idx->constEnd(); ++it) { - const FunctionInfo *info = it.value().data(); - if (seenInfos.contains(info)) { - continue; - } - seenInfos.insert(info); - QJsonObject o; - o["objectId"] = info->objectId; - o["chineseName"] = info->chineseName; - o["canonicalName"] = info->canonicalName; - o["filePath"] = info->filePath; - o["relDir"] = info->relDir; - o["module"] = info->module; - o["isExternal"] = info->isExternal; - QJsonArray calls; - for (const QString &c : info->calledFunctions) { - calls.append(c); - } - o["calledFunctions"] = calls; - funcs.append(o); + for (const QSharedPointer &info : m_allInfos) { + QJsonObject o; + o["objectId"] = info->objectId; + o["chineseName"] = info->chineseName; + o["canonicalName"] = info->canonicalName; + o["filePath"] = info->filePath; + o["relDir"] = info->relDir; + o["module"] = info->module; + o["isExternal"] = info->isExternal; + QJsonArray calls; + for (const QString &c : info->calledFunctions) { + calls.append(c); } + o["calledFunctions"] = calls; + funcs.append(o); } QJsonObject meta; @@ -335,8 +349,8 @@ void CallChainAnalyzer::saveToCache() const bool CallChainAnalyzer::loadFromCache(const QString &path, QString *message, bool skipFingerprint) { m_chineseNameIndex.clear(); - m_fullNameIndex.clear(); m_canonicalNameIndex.clear(); + m_allInfos.clear(); m_indexed = false; if (path.isEmpty() || !QDir(path).exists()) { @@ -345,12 +359,19 @@ bool CallChainAnalyzer::loadFromCache(const QString &path, QString *message, boo const QString cacheFile = cacheFilePathFor(path); QFile file(cacheFile); - if (!file.exists() || !file.open(QIODevice::ReadOnly)) { + if (!file.exists()) { + return false; // 首次运行无缓存属正常, 不告警(调用方按"无缓存"重建) + } + if (!file.open(QIODevice::ReadOnly)) { + // 缓存文件存在但打不开(权限/被占用): 与"无缓存"区分, 告警定位排障方向 + LogManager::instance()->logWarning(QString("调用链缓存文件存在但无法打开, 将全量重建: %1").arg(cacheFile)); return false; } const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (!doc.isObject()) { + if (doc.isNull()) { + // 缓存文件存在但 JSON 非法(写盘中断/外部篡改): 与"首次无缓存"区分, 告警定位 + LogManager::instance()->logWarning(QString("调用链缓存 JSON 解析失败, 将全量重建: %1").arg(cacheFile)); return false; } @@ -390,34 +411,20 @@ bool CallChainAnalyzer::loadFromCache(const QString &path, QString *message, boo } // 同名冲突同样优先“真正的实现” - auto itCn = m_chineseNameIndex.find(info->chineseName); - if (itCn == m_chineseNameIndex.end()) { - m_chineseNameIndex.insert(info->chineseName, info); - } else if (shouldPrefer(itCn.value().data(), info.data())) { - itCn.value() = info; - } - if (!info->objectId.isEmpty()) { - const QString fullKey = info->objectId + "-" + info->chineseName; - auto itFull = m_fullNameIndex.find(fullKey); - if (itFull == m_fullNameIndex.end()) { - m_fullNameIndex.insert(fullKey, info); - } else if (shouldPrefer(itFull.value().data(), info.data())) { - itFull.value() = info; - } - } + insertPrefer(m_chineseNameIndex, info->chineseName, info); if (!info->canonicalName.isEmpty()) { - auto itCan = m_canonicalNameIndex.find(info->canonicalName); - if (itCan == m_canonicalNameIndex.end()) { - m_canonicalNameIndex.insert(info->canonicalName, info); - } else if (shouldPrefer(itCan.value().data(), info.data())) { - itCan.value() = info; - } + insertPrefer(m_canonicalNameIndex, info->canonicalName, info); } + // 缓存导出顺序即加载顺序, 全量集合与索引同步重建 + m_allInfos.append(info); ++count; } m_rootPath = path; m_indexed = true; + // 失效指纹统计: 统计值属于上次 buildIndex 遍历, 与缓存加载的索引不对应; + // 若置之不理, loadFromCache 后再 saveToCache 会把陈旧统计写入 meta.fingerprint + m_fpStatsValid = false; rebuildReverseIndex(); if (message) { *message = QString("已从缓存加载 %1 个函数/服务文件").arg(count); @@ -429,60 +436,29 @@ QSharedPointer CallChainAnalyzer::lookupBest(const QString &name) const { // 责任链:按优先级依次尝试多种查找策略,任一命中即返回;全部落空返回 nullptr。 - // 新增/调整查找策略时只需在此列表中增删条目,无需改动调用方。 - using Strategy = std::function()>; + // 直接顺序哈希查找(不构造 std::function/QVector): 本函数被 rebuildReverseIndex + // 的 N×M 嵌套循环高频调用, 每次 4 次堆分配在索引大时会放大为百万级 malloc。 const QString transSuffix = QString::fromUtf8("_转码"); - const QVector strategies = { - // 1) 精确匹配 chineseName - [&]() -> QSharedPointer { - return m_chineseNameIndex.value(name); - }, - // 2) 精确匹配 canonicalName(文件 basename) - [&]() -> QSharedPointer { - return m_canonicalNameIndex.value(name); - }, - // 3) 添加 "_转码" 后缀匹配(部分 .uftservice 的 chineseName 带有 _转码) - [&]() -> QSharedPointer { - return m_chineseNameIndex.value(name + transSuffix); - }, - // 4) 去除 "_转码" 后缀匹配 - [&]() -> QSharedPointer { - if (name.endsWith(transSuffix)) { - return m_chineseNameIndex.value(name.chopped(transSuffix.length())); - } - return nullptr; - }, - }; + // 1) 精确匹配 chineseName + if (auto info = m_chineseNameIndex.value(name)) { + return info; + } + // 2) 精确匹配 canonicalName(文件 basename) + if (auto info = m_canonicalNameIndex.value(name)) { + return info; + } + // 3) 添加 "_转码" 后缀匹配(部分 .uftservice 的 chineseName 带有 _转码) + if (auto info = m_chineseNameIndex.value(name + transSuffix)) { + return info; + } + // 4) 去除 "_转码" 后缀匹配 + if (name.endsWith(transSuffix)) { + return m_chineseNameIndex.value(name.chopped(transSuffix.length())); + } // 注: 原策略5("前缀+_转码"遍历整表线性扫描)与策略3数学恒等—— // 满足 startsWith(name) && mid(name.length())==suffix 的 key 必然等于 name+suffix, // 直接哈希查找即可, 删除可避免索引变大后每次 lookupBest 的 O(N) 全表扫描。 - - for (const Strategy &strategy : strategies) { - if (auto info = strategy()) { - return info; - } - } - return nullptr; -} - -QSharedPointer -CallChainAnalyzer::findByChineseName(const QString &name) const -{ - auto it = m_chineseNameIndex.find(name); - if (it != m_chineseNameIndex.end()) { - return it.value(); - } - return nullptr; -} - -QSharedPointer -CallChainAnalyzer::findByFullName(const QString &fullName) const -{ - auto it = m_fullNameIndex.find(fullName); - if (it != m_fullNameIndex.end()) { - return it.value(); - } return nullptr; } @@ -505,12 +481,12 @@ void CallChainAnalyzer::resolveNode(const QString &name, TreeNode &node, bool is node.displayName = info->objectId.isEmpty() ? info->chineseName : QString("%1-%2").arg(info->objectId, info->chineseName); - node.state = "ok"; + node.state = Constants::CallTree::StateOk; } else { node.displayName = name; // 根节点未找到:整个函数不在索引中 // 子调用未找到:说明该函数无需转码,原代码已支持 - node.state = isRoot ? "notfound" : "supported"; + node.state = isRoot ? Constants::CallTree::StateNotFound : Constants::CallTree::StateSupported; // 若调用名称本身以 "功能号-中文名" 形式传入,解析出功能号用于导出展示 int dash = name.indexOf('-'); if (dash > 0) { @@ -533,7 +509,7 @@ void CallChainAnalyzer::buildNode(const QString &name, TreeNode &node, resolveNode(name, node, isRoot); if (depth >= maxDepth) { - node.state = "depthlimit"; + node.state = Constants::CallTree::StateDepthLimit; return; } @@ -542,7 +518,7 @@ void CallChainAnalyzer::buildNode(const QString &name, TreeNode &node, // 调用图下按“根→叶不同路径数”重复枚举共享子树, 节点数随深度指数增长导致 UI 卡死; // 全局共享后节点总数受索引规模(总边数)约束。 if (visited.contains(name)) { - node.state = "cycle"; + node.state = Constants::CallTree::StateCycle; return; } @@ -603,7 +579,6 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d { // 链数硬上限: 反向索引含菱形汇聚/大环时, per-path 枚举会指数爆炸; // 保证 UI 线程计算有上界, 超出部分以 depthlimit 单节点收尾。 - static const int kMaxChains = 200; int chainCount = 0; QSet visited; @@ -637,7 +612,7 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d // 全局去重: name 已由其它链展开过时截断为单节点, 不重复枚举其整棵上游子树 if (name != startKey && visited.contains(name)) { TreeNode cn = makeNode(name, false); - cn.state = "cycle"; + cn.state = Constants::CallTree::StateCycle; out.append(cn); return out; } @@ -658,10 +633,10 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d for (const QSharedPointer &C : callers) { const QString cName = C->chineseName; - if (chainCount >= kMaxChains) { + if (chainCount >= Constants::CallTree::MaxCallerChains) { // 链数超上限: 以 depthlimit 单节点收尾(非空链), 保证整体产出仍可展示 TreeNode n = makeNode(cName, false); - n.state = "depthlimit"; + n.state = Constants::CallTree::StateDepthLimit; out.append(n); ++chainCount; continue; @@ -712,6 +687,7 @@ CallChainAnalyzer::parseFile(const QString &filePath) const bool inCodeElement = false; bool isRoot = true; QString codeText; + codeText.reserve(static_cast(fi.size())); // code 元素占文件主体, 预分配避免循环拼接反复扩容 while (!reader.atEnd() && !reader.hasError()) { reader.readNext(); @@ -783,8 +759,8 @@ QStringList CallChainAnalyzer::extractFunctionCalls(const QString &code) if (name.isEmpty() || seen.contains(name)) { continue; } - if (name.startsWith("LS_") || name.startsWith("LF_") || - name.startsWith("AS_") || name.startsWith("AF_")) { + if (name.startsWith(Constants::FuncName::LsPrefix) || name.startsWith(Constants::FuncName::LfPrefix) || + name.startsWith(Constants::FuncName::AsPrefix) || name.startsWith(Constants::FuncName::AfPrefix)) { seen.insert(name); result.append(name); } diff --git a/src/utils/callchainanalyzer.h b/src/utils/callchainanalyzer.h index b5e4944..98998b9 100644 --- a/src/utils/callchainanalyzer.h +++ b/src/utils/callchainanalyzer.h @@ -43,7 +43,6 @@ public: QString rootPath() const; bool isIndexed() const; - int functionCount() const; // 构建索引:扫描根目录下所有 .uftfunction 文件 bool buildIndex(QString *errorMessage = nullptr); @@ -59,11 +58,6 @@ public: // 避免每次启动都做全量目录指纹扫描(耗时 ~2s)。命中不代表一定有效,仍需 loadFromCache 终判。 bool cacheQuickValid(const QString &path) const; - // 根据函数中文名查找 - QSharedPointer findByChineseName(const QString &name) const; - // 根据 objectId-chineseName 查找 - QSharedPointer findByFullName(const QString &fullName) const; - // 多策略查找:先精确匹配 chineseName,再尝试 basename、去后缀等。 // 与 resolveNode 使用同一解析入口,供外部还原节点的展示名(如蓝底高亮匹配)。 QSharedPointer lookupBest(const QString &name) const; @@ -91,9 +85,11 @@ private: static QStringList extractFunctionCalls(const QString &code); static QString stripComments(const QString &code); - // 缓存辅助:根路径 -> 缓存文件路径(按路径哈希命名),以及目录内容指纹(用于失效判断) + // 缓存辅助:根路径 -> 缓存文件路径(按路径哈希命名),以及目录内容指纹(用于失效判断)。 + // computeFingerprint 优先复用 buildIndex 遍历时顺带统计的 m_fp* 数据 + // (紧随其后的 saveToCache 省去主线程 ~2s 重复全树 stat), 未命中才全树扫描。 static QString cacheFilePathFor(const QString &rootPath); - static QString computeFingerprint(const QString &rootPath); + QString computeFingerprint(const QString &rootPath) const; // 仅列举 root 直接子项(不递归)的轻量指纹,O(直接子项数) 次 stat,远快于 computeFingerprint static QString quickFingerprint(const QString &rootPath); // 快速指纹 sidecar 文件路径(与缓存同名、扩展名 .fp),存放 rootPath + 轻量指纹 @@ -101,11 +97,21 @@ private: QString m_rootPath; QHash> m_chineseNameIndex; - QHash> m_fullNameIndex; QHash> m_canonicalNameIndex; + // 全量函数集合(buildIndex/loadFromCache 两条构建路径的共同底座): + // saveToCache 的单一导出事实来源, 天然无重复, 索引哈希则允许同名落选变体缺席 + QList> m_allInfos; // 反向索引:被调用函数名 -> 调用它的函数列表(用于“往前查”) QHash>> m_reverseIndex; bool m_indexed = false; + + // buildIndex 遍历时顺带统计的目录指纹数据(口径与全树扫描一致): + // 后台线程写入, 经 QFutureWatcher::finished 信号排队传递后主线程读取, + // happens-before 由信号机制建立, 无需加锁。仅在本次索引遍历内有效。 + qint64 m_fpCount = 0; + qint64 m_fpTotalSize = 0; + qint64 m_fpLatestMtime = 0; + bool m_fpStatsValid = false; }; #endif // CALLCHAINANALYZER_H diff --git a/src/utils/configmanager.cpp b/src/utils/configmanager.cpp index d6ad036..6b22eee 100644 --- a/src/utils/configmanager.cpp +++ b/src/utils/configmanager.cpp @@ -49,24 +49,44 @@ bool ConfigManager::isValidUtf8(const QByteArray& data) // 否则白名单之外的键经 load(\→/) 后 save 不换回, 首次加载即被永久改写 static bool isPathLikeKey(const QString& section, const QString& key) { - static const QStringList keys = {"uft30", "uf20", "ufAcct20", "createPath", "uft30pub"}; - return section == "projectPath" && keys.contains(key); + static const QStringList keys = { + Constants::ConfigKeys::PpUft30, Constants::ConfigKeys::PpUf20, + Constants::ConfigKeys::PpUfAcct20, Constants::ConfigKeys::PpUftdbSett, + Constants::ConfigKeys::PpCreatePath, Constants::ConfigKeys::PpUft30Pub + }; + return section == Constants::ConfigKeys::SectionProjectPath && keys.contains(key); +} + +// section/键值行识别正则(函数级 static 保证只编译一次): parseIniContent 与 saveConfig 共用 +static const QRegularExpression& sectionLineRegex() +{ + // section 必须整行匹配: 旧正则对任意位置匹配, 值行含 "[...]" 或行尾注释会被误判为新 section 丢键 + static const QRegularExpression re("^\\s*\\[([^\\]\\r\\n]+)\\]\\s*$"); + return re; +} + +static const QRegularExpression& keyValueLineRegex() +{ + // 值允许为空: 用户显式写 "key=" 意图清空该项, 旧正则要求值非空导致该行被静默丢弃 + static const QRegularExpression re("^\\s*([^=\\s]+)\\s*=(.*)$", QRegularExpression::MultilineOption); + return re; } // ini 文本 -> 嵌套 map(loadConfig/saveConfig 共用的解析逻辑, 避免两处正则漂移) static QMap> parseIniContent(const QString& content) { - // section 必须整行匹配: 旧正则对任意位置匹配, 值行含 "[...]" 或行尾注释会被误判为新 section 丢键 - static const QRegularExpression sectionRegex("^\\s*\\[([^\\]\\r\\n]+)\\]\\s*$"); - // 值允许为空: 用户显式写 "key=" 意图清空该项, 旧正则要求值非空导致该行被静默丢弃 - static const QRegularExpression keyValueRegex("^\\s*([^=\\s]+)\\s*=(.*)$", QRegularExpression::MultilineOption); - QMap> cache; QString currentSection; const QStringList lines = content.split('\n'); for (const QString& line : lines) { - QRegularExpressionMatch sectionMatch = sectionRegex.match(line); + // 注释行(#/; 开头)整行跳过: 含 "=" 的注释(如 "# maxsize = 10MB")若落入下方键值匹配, + // 会生成垃圾键 "#" 且 saveConfig 保留布局写回时被改写为 "#=maxsize = 10MB" 污染手工注释 + const QString trimmedLine = line.trimmed(); + if (trimmedLine.startsWith(QLatin1Char('#')) || trimmedLine.startsWith(QLatin1Char(';'))) { + continue; + } + QRegularExpressionMatch sectionMatch = sectionLineRegex().match(line); if (sectionMatch.hasMatch()) { currentSection = sectionMatch.captured(1).trimmed(); // 同名 section 重复出现时合并(不清空), 防止手工维护 ini 追加段后前段键被清空 @@ -80,7 +100,7 @@ static QMap> parseIniContent(const QString& cont continue; } - QRegularExpressionMatch keyValueMatch = keyValueRegex.match(line); + QRegularExpressionMatch keyValueMatch = keyValueLineRegex().match(line); if (keyValueMatch.hasMatch()) { QString key = keyValueMatch.captured(1).trimmed(); QString value = keyValueMatch.captured(2).trimmed(); @@ -94,9 +114,8 @@ static QMap> parseIniContent(const QString& cont return cache; } -bool ConfigManager::loadConfig(const QString& basePath) +bool ConfigManager::loadConfig() { - m_basePath = basePath; m_configFilePath = Constants::configIni(); LogManager::instance()->log(QString("========== 加载配置文件 ==========")); @@ -104,6 +123,9 @@ bool ConfigManager::loadConfig(const QString& basePath) QFile file(m_configFilePath); if (!file.open(QIODevice::ReadOnly)) { + // 打开失败时清空旧缓存: 保留旧值会让"文件已删除/不可读"被误判为"配置仍有效", + // getValue 拿到陈旧路径继续工作, 排障方向被误导 + m_configCache.clear(); LogManager::instance()->logError(QString("无法打开配置文件: %1").arg(m_configFilePath)); return false; } @@ -139,7 +161,14 @@ QString ConfigManager::getValue(const QString& section, const QString& key, cons void ConfigManager::setValue(const QString& section, const QString& key, const QString& value) { - m_configCache[section][key] = value; + // 路径键归一化为正斜杠, 与 loadConfig 的归一化对称: + // 缓存恒为正斜杠, 写盘时(saveConfig)再统一换回反斜杠, 任何来源(手编/代码/界面)不再漂移 + QString normalized = value; + if (isPathLikeKey(section, key)) { + normalized.replace("\\", "/"); + } + m_configCache[section][key] = normalized; + m_dirtyKeys.insert(section + QLatin1Char('\x01') + key); LogManager::instance()->log(QString("配置更新: [%1] %2 = %3").arg(section).arg(key).arg(value)); } @@ -147,46 +176,121 @@ bool ConfigManager::saveConfig() { LogManager::instance()->log(QString("========== 保存配置文件 ==========")); LogManager::instance()->log(QString("配置文件路径: %1").arg(m_configFilePath)); - + + // 覆盖窗口保护: ini 在加载后被外部手编(mtime 变化)时, + // 对应用未显式 setValue 过的键采纳磁盘值(外部已有键的修改不再被内存覆盖), + // 显式修改过的键以内存缓存为准, 由 UI 写回语义保证 bool isUtf8 = true; + QString content; if (QFile::exists(m_configFilePath)) { QFile readFile(m_configFilePath); if (readFile.open(QIODevice::ReadOnly)) { - QByteArray data = readFile.readAll(); + const QByteArray data = readFile.readAll(); readFile.close(); isUtf8 = isValidUtf8(data); + content = isUtf8 ? QString::fromUtf8(data) : QString::fromLocal8Bit(data); - // 覆盖窗口保护: ini 在加载后被外部手编(mtime 变化), 内存缓存写回会覆盖外部修改; - // 把外部新增而内存没有的 section/键合并进缓存, 外部追加的手工配置不丢失 - // (应用显式 setValue 过的键以内存缓存为准, 由 UI 写回语义保证) const QDateTime nowMtime = QFileInfo(m_configFilePath).lastModified(); if (nowMtime != m_loadedMtime) { - QString extContent; - if (isValidUtf8(data)) { - extContent = QString::fromUtf8(data); - } else { - extContent = QString::fromLocal8Bit(data); - } - const auto external = parseIniContent(extContent); - int merged = 0; + const auto external = parseIniContent(content); + int adopted = 0; for (auto sit = external.constBegin(); sit != external.constEnd(); ++sit) { for (auto kit = sit.value().constBegin(); kit != sit.value().constEnd(); ++kit) { - if (!m_configCache.contains(sit.key()) || - !m_configCache[sit.key()].contains(kit.key())) { - m_configCache[sit.key()][kit.key()] = kit.value(); - ++merged; + if (m_dirtyKeys.contains(sit.key() + QLatin1Char('\x01') + kit.key())) { + continue; // UI 显式写回的键: 以内存缓存为准 + } + QString extVal = kit.value(); + if (isPathLikeKey(sit.key(), kit.key())) { + extVal.replace("\\", "/"); // 与 loadConfig 的归一化对称 + } + if (m_configCache[sit.key()][kit.key()] != extVal) { + m_configCache[sit.key()][kit.key()] = extVal; + ++adopted; } } } - if (merged > 0) { + if (adopted > 0) { LogManager::instance()->logWarning( - QString("config.ini 在加载后被外部修改, 已合并 %1 个新增 section/键再写回").arg(merged)); + QString("config.ini 在加载后被外部修改, 已采纳 %1 处外部值再写回(UI 显式设置的键不受影响)").arg(adopted)); } m_loadedMtime = nowMtime; } + } else { + // 文件存在但读取失败(被占用/权限不足): 中止本次保存, 保留磁盘原文件。 + // 若继续以空 content 走"保留布局式写回", 会退化为全量重排输出, + // 手工注释/空行/节顺序将永久丢失且无任何提示 + LogManager::instance()->logError( + QString("保存配置失败: 无法读取 config.ini(%1), 已中止本次保存, 磁盘文件保持原样") + .arg(readFile.errorString())); + return false; } } - + + // 保留布局式写回: 以磁盘当前内容为基底逐行输出, 仅"显式修改过"的键行替换为缓存值; + // 手工注释/空行/节顺序/外部新增键一律原样保留; 缓存中有而文件没有的键在该节末尾追加, + // 文件中没有的节整体追加。(旧实现按 QMap 全量重排输出, 一次保存即清掉手工注释与键顺序, 已废弃) + auto formatKV = [](const QString& section, const QString& key, const QString& rawValue) { + QString value = rawValue; + if (isPathLikeKey(section, key)) { + value.replace("/", "\\"); // 与 setValue/loadConfig 的正向转换对称: 仅路径白名单键换回反斜杠 + } + return QString("%1=%2").arg(key).arg(value); + }; + + QMap> pending = m_configCache; // 逐行消费副本 + QStringList out; + QString currentSection; + // 节结束(下一个节行/文件尾)时, 把该节缓存有而文件未出现的键追加在节尾 + auto flushPending = [&](const QString& section) { + if (section.isEmpty() || !pending.contains(section)) { + return; + } + for (auto kit = pending[section].constBegin(); kit != pending[section].constEnd(); ++kit) { + out << formatKV(section, kit.key(), kit.value()); + } + pending.remove(section); + }; + + const QStringList lines = content.isEmpty() ? QStringList() : content.split('\n'); + for (const QString& rawLine : lines) { + QString line = rawLine; + if (line.endsWith('\r')) { + line.chop(1); // split('\n') 会残留行尾 \r: 统一去掉, 行结束符交给 QIODevice::Text 按平台还原 + } + QRegularExpressionMatch secMatch = sectionLineRegex().match(line); + if (secMatch.hasMatch()) { + flushPending(currentSection); // 上一节收尾 + out << line; + currentSection = secMatch.captured(1).trimmed(); + // 同名节重复出现: 首次 flushPending 后 pending 已移除该节, 不重复追加 + continue; + } + QRegularExpressionMatch kvMatch = keyValueLineRegex().match(line); + if (!currentSection.isEmpty() && kvMatch.hasMatch()) { + const QString key = kvMatch.captured(1).trimmed(); + if (pending.contains(currentSection) && pending[currentSection].contains(key)) { + out << formatKV(currentSection, key, pending[currentSection].value(key)); + pending[currentSection].remove(key); + if (pending[currentSection].isEmpty()) { + pending.remove(currentSection); + } + continue; + } + } + out << line; // 注释/空行/缓存外的键行: 原样保留 + } + flushPending(currentSection); // 最后一节收尾 + // 缓存中存在而文件中完全没有的节 → 整体追加 + for (auto sit = pending.constBegin(); sit != pending.constEnd(); ++sit) { + if (!out.isEmpty() && !out.last().trimmed().isEmpty()) { + out << ""; // 节间空行, 与手工维护格式一致 + } + out << QString("[%1]").arg(sit.key()); + for (auto kit = sit.value().constBegin(); kit != sit.value().constEnd(); ++kit) { + out << formatKV(sit.key(), kit.key(), kit.value()); + } + } + // QSaveFile: 先写临时文件、成功后原子替换, 避免写入中途崩溃损坏 config.ini // 注意: QSaveFile 不会自动创建缺失的父目录, config/ 目录必须先 mkpath // (mkpath 对已存在目录幂等无害), 否则目录缺失时 open 失败静默丢失配置 @@ -196,43 +300,26 @@ bool ConfigManager::saveConfig() LogManager::instance()->logError(QString("无法打开配置文件进行写入: %1").arg(m_configFilePath)); return false; } - - QString content; - for (const QString& section : m_configCache.keys()) { - content += QString("[%1]\n").arg(section); - for (const QString& key : m_configCache[section].keys()) { - QString value = m_configCache[section][key]; - // 与 loadConfig 的正向转换对称: 仅路径白名单键换回反斜杠 - if (isPathLikeKey(section, key)) { - value.replace("/", "\\"); - } - content += QString("%1=%2\n").arg(key).arg(value); - } - content += "\n"; - } - + + const QString newContent = out.join('\n'); QByteArray outData; if (isUtf8) { - outData = content.toUtf8(); + outData = newContent.toUtf8(); } else { - outData = content.toLocal8Bit(); + outData = newContent.toLocal8Bit(); } file.write(outData); if (!file.commit()) { LogManager::instance()->logError(QString("配置文件保存失败: %1").arg(file.errorString())); return false; } + m_dirtyKeys.clear(); // 写盘成功后内存与磁盘一致 LogManager::instance()->log("配置文件保存完成"); return true; } -QString ConfigManager::getBasePath() const -{ - return m_basePath; -} - QString ConfigManager::getConfigFilePath() const { return m_configFilePath; diff --git a/src/utils/configmanager.h b/src/utils/configmanager.h index e9a6a2a..ea6dc46 100644 --- a/src/utils/configmanager.h +++ b/src/utils/configmanager.h @@ -4,6 +4,7 @@ #include #include #include +#include #include class ConfigManager : public QObject @@ -12,14 +13,13 @@ class ConfigManager : public QObject public: static ConfigManager* instance(); - bool loadConfig(const QString& basePath); - + bool loadConfig(); + QString getValue(const QString& section, const QString& key, const QString& defaultValue = QString()); void setValue(const QString& section, const QString& key, const QString& value); - + bool saveConfig(); - - QString getBasePath() const; + QString getConfigFilePath() const; private: @@ -27,12 +27,15 @@ private: ~ConfigManager(); bool isValidUtf8(const QByteArray& data); - - QString m_basePath; + QString m_configFilePath; QMap> m_configCache; // loadConfig 时的文件 mtime: saveConfig 据此探测加载后 ini 被外部手编, 防覆盖窗口 QDateTime m_loadedMtime; + // setValue 显式修改过的键: saveConfig 时以此区分 + // "UI 写回的键"(以内存为准) 与 "外部手编的键"(采纳磁盘值), 防止覆盖外部已有键的修改 + // 存储 "section\x01key" 组合串(QSet 无 qHash 特化, 用分隔符规避节/键名冲突) + QSet m_dirtyKeys; }; diff --git a/src/utils/configreaderbase.cpp b/src/utils/configreaderbase.cpp index 9e94a1b..cbfa18e 100644 --- a/src/utils/configreaderbase.cpp +++ b/src/utils/configreaderbase.cpp @@ -2,7 +2,6 @@ #include "Constants.h" #include "datacache.h" #include "logmanager.h" -#include "configmanager.h" #include #include @@ -19,7 +18,7 @@ bool ConfigReaderBase::doLoadAllConfig(const ConfigReaderTraits& traits) // 元数据解析结果直接落双库(cache/uf20_cache.db / cache/uft3_cache.db), // 不再生成中间 JSON(uf2.json/uft3.json)。因此只需校验接口表非空即可加载。 - if (traits.hasConfig(traits.mainFile)) { + if (traits.hasConfig()) { m_loaded = true; DataCache::instance()->setDataLoaded(true); return true; @@ -41,7 +40,7 @@ bool ConfigReaderBase::doReloadConfig(const ConfigReaderTraits& traits) return false; } - if (!traits.hasConfig(traits.mainFile)) { + if (!traits.hasConfig()) { LogManager::instance()->logError( QString("%1配置重新加载失败 - 接口表为空, 请先执行元数据更新").arg(traits.logTag)); return false; @@ -67,18 +66,28 @@ bool ConfigReaderBase::doLoadCnameCache(const ConfigReaderTraits& traits, return false; } - cache = traits.getAllCnames(); - // 接口表为空(首次启动或元数据更新尚未落库完成)时数据尚未就绪: - // 此时不标记为“已加载”, 保留 cacheLoaded=false 以便后续查询重新加载, - // 避免把这一时刻的空结果长期固化为“功能不存在”。 - const bool dataReady = traits.hasConfig ? traits.hasConfig(traits.mainFile) : true; - if (cache.isEmpty() && !dataReady) { + // 在全量重建之前先判就绪, 避免白扫一遍只得到注定为空的结果; + // 保持 cacheLoaded=false 以便后续查询重新加载, + // 避免把这一时刻的空结果长期固化为"功能不存在"。 + if (traits.hasConfig && !traits.hasConfig()) { LogManager::instance()->logInfo( QString("%1 Cname缓存暂未加载 - 接口表为空, 数据未就绪, 后续查询将重试").arg(traits.logTag)); return false; } + // 全量重建需全表扫描+解析数万行 JSON(可达数秒), 同步执行会阻塞主线程 + // (启动期页面构造先于 singleShot 预热提交, 这里是批量路径的预热保护, + // 与 doCheckFuncExists 的单条路径对齐)。触发后台预热后仍未就绪时返回空集: + // 存在性判定方(getAllCnamesStatic 的调用方)拿到空集会逐行回退单条查库兜底, + // 预热完成后自动切换为内存集合快路径。 + if (traits.warmupCache && !traits.warmupCache()) { + LogManager::instance()->logInfo( + QString("%1 Cname缓存预热中, 本次返回空集, 存在性判定走单条查库兜底").arg(traits.logTag)); + return false; + } + + cache = traits.getAllCnames(); cacheLoaded = true; return true; } diff --git a/src/utils/configreaderbase.h b/src/utils/configreaderbase.h index aae80bd..5b69651 100644 --- a/src/utils/configreaderbase.h +++ b/src/utils/configreaderbase.h @@ -4,8 +4,6 @@ #include #include #include -#include -#include #include #include @@ -15,12 +13,7 @@ struct ConfigReaderTraits { QString logTag; // 日志前缀,如 "UF20" / "UFT3" QString mainFile; // 主配置文件名,如 "uf2.json" - // 重载时读取的文件列表:{文件名, 是否必需}。 - // required=true -> 打不开或解析失败即整体失败(logError) - // required=false -> 打不开或解析失败则跳过(logWarning) - QVector> reloadFiles; - std::function hasConfig; // DataCache 是否已缓存该配置 - std::function saveConfig; // DataCache 保存配置 + std::function hasConfig; // DataCache 是否已缓存该配置(接口表非空) std::function()> getAllCnames; // DataCache 读取全部 cname // 触发功能缓存后台预热(不阻塞调用线程)。返回 true 表示内存缓存已就绪可立即派生 cname 集合; // 返回 false 表示预热已在后台进行, 查询方应走单条查库兜底 diff --git a/src/utils/custjsonio.cpp b/src/utils/custjsonio.cpp new file mode 100644 index 0000000..e90bfb8 --- /dev/null +++ b/src/utils/custjsonio.cpp @@ -0,0 +1,190 @@ +#include "custjsonio.h" +#include +#include +#include +#include +#include +#include +#include "logmanager.h" + +namespace CustJsonIo { + +QStringList extractTopLevelKeyOrder(const QString &rawJson) +{ + QStringList keys; + int depth = 0; + bool inString = false; + bool escaped = false; + int strStart = -1; + for (int i = 0; i < rawJson.length(); ++i) { + const QChar ch = rawJson[i]; + if (inString) { + if (escaped) { escaped = false; } + else if (ch == '\\') { escaped = true; } + else if (ch == '"') { + inString = false; + // 字符串闭合后跳过空白, 紧跟冒号且处于最外层对象内 → 顶层键 + int j = i + 1; + while (j < rawJson.length() && rawJson[j].isSpace()) ++j; + if (depth == 1 && strStart >= 0 && j < rawJson.length() + && rawJson[j] == ':') { + const QString key = rawJson.mid(strStart + 1, i - strStart - 1); + if (!key.isEmpty() && !keys.contains(key)) { + keys.append(key); + } + } + } + } else { + if (ch == '"') { inString = true; strStart = i; } + else if (ch == '{' || ch == '[') { ++depth; } + else if (ch == '}' || ch == ']') { --depth; } + } + } + return keys; +} + +QString indentJsonValue(const QString &json) +{ + if (json.isEmpty()) return json; + QStringList lines = json.split('\n'); + QString result; + result.reserve(json.size() + lines.size() * 5); // 预分配: 每行最多补 4 空格缩进 + 换行 + for (const QString &line : lines) { + if (!line.trimmed().isEmpty()) + result += " " + line + "\n"; + } + return result; +} + +QString escapeJsonString(const QString &value) +{ + QString escaped = value; + escaped.replace("\\", "\\\\"); + escaped.replace("\"", "\\\""); + escaped.replace("\n", "\\n"); + escaped.replace("\r", "\\r"); + escaped.replace("\t", "\\t"); + escaped.replace("\b", "\\b"); + escaped.replace("\f", "\\f"); + // 其余 <0x20 控制字符按 \uXXXX 转义: 原样写出会产生严格意义上非法的 JSON, + // 下次解析(readJson/extractTopLevelKeyOrder)将失败且难定位 + QString out; + out.reserve(escaped.size() + 16); + for (const QChar ch : escaped) { + if (ch.unicode() < 0x20) { + out += QStringLiteral("\\u%1").arg(ch.unicode(), 4, 16, QLatin1Char('0')); + } else { + out += ch; + } + } + return out; +} + +QString formatJsonValue(const QJsonValue &value) +{ + if (value.isObject()) { + QJsonDocument doc(value.toObject()); + QString raw = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); + return indentJsonValue(raw); + } else if (value.isArray()) { + QJsonDocument doc(value.toArray()); + QString raw = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); + return indentJsonValue(raw); + } else if (value.isString()) { + return "\"" + escapeJsonString(value.toString()) + "\""; + } else if (value.isBool()) { + return value.toBool() ? "true" : "false"; + } else if (value.isDouble()) { + double d = value.toDouble(); + // double→int 直接 static_cast 在超出 int 范围(含 NaN/Inf)时是未定义行为, + // 先判范围: 越界或非有限值统一按浮点格式输出 + if (d >= std::numeric_limits::min() && d <= std::numeric_limits::max() + && d == static_cast(d)) + return QString::number(static_cast(d)); + return QString::number(d, 'f'); + } + return "null"; +} + +QString formatOrderedJsonObject(const QList> &pairs) +{ + QString result = "{\n"; + qsizetype total = 8; + for (const auto &pair : pairs) + total += pair.first.size() + pair.second.size() + 32; // 引号/冒号/缩进/换行余量 + result.reserve(total); + bool first = true; + for (const auto &pair : pairs) { + if (!first) result += ",\n"; + first = false; + result += " \"" + escapeJsonString(pair.first) + "\": \"" + escapeJsonString(pair.second) + "\""; + } + result += "\n }"; + return result; +} + +QString formatOrderedJsonArray(const QStringList &items) +{ + if (items.isEmpty()) return "[]"; + QString result = "[\n"; + qsizetype total = 8; + for (const QString &item : items) + total += item.size() + 32; // 引号/缩进/换行余量 + result.reserve(total); + bool first = true; + for (const QString &item : items) { + if (!first) result += ",\n"; + first = false; + result += " \"" + escapeJsonString(item) + "\""; + } + result += "\n ]"; + return result; +} + +bool writeCustJsonOrdered(const QString &jsonPath, + const QJsonObject &json, + const QStringList &keyOrder, + const QHash &overrideRaw) +{ + // QSaveFile 原子写: 先写临时文件成功后再 commit 替换目标, + // 避免写一半崩溃/断电留下截断损坏的 cust.json(QFile+Truncate 会先清空原文件) + QSaveFile file(jsonPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + LogManager::instance()->logError("无法写入 cust.json: " + jsonPath); + return false; + } + + QTextStream out(&file); + out << "{\n"; + bool first = true; + for (const QString &key : keyOrder) { + if (!json.contains(key)) continue; + if (!first) out << ",\n"; + first = false; + // 键名同样转义: 值均经 escapeJsonString, 键名若含引号/反斜杠同样会产出非法 JSON + out << " \"" << escapeJsonString(key) << "\": "; + if (overrideRaw.contains(key)) { + out << overrideRaw[key]; + } else { + out << formatJsonValue(json[key]); + } + } + // keyOrder 只记录原文件已有的键; 内存对象中"原文件没有"的新增顶层键也必须写出, + // 否则新页首次保存会被静默丢弃(UI 仍报成功), 重启后配置消失 + for (auto it = json.constBegin(); it != json.constEnd(); ++it) { + if (keyOrder.contains(it.key())) continue; + if (!first) out << ",\n"; + first = false; + out << " \"" << escapeJsonString(it.key()) << "\": " << formatJsonValue(it.value()); + } + out << "\n}\n"; + out.flush(); + if (!file.commit()) { + LogManager::instance()->logError( + QString("cust.json 提交失败(磁盘空间/权限/占用?): %1").arg(jsonPath)); + return false; + } + return true; +} + +} // namespace CustJsonIo diff --git a/src/utils/custjsonio.h b/src/utils/custjsonio.h new file mode 100644 index 0000000..b4d6502 --- /dev/null +++ b/src/utils/custjsonio.h @@ -0,0 +1,46 @@ +#ifndef CUSTJSONIO_H +#define CUSTJSONIO_H + +#include +#include +#include +#include +#include +#include + +// cust.json 保序读写公共工具: 表替换/函数替换/不转码功能函数等页面共用, +// 保证保存后顶层键顺序与原文件一致、只重写本页面负责的键 +namespace CustJsonIo { + +// 从原始 JSON 文本提取顶层 key 顺序: +// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键; +// 正则方案无法匹配中文/特殊字符键且会误捕嵌套键, 导致保存时顶层字段丢失 +QStringList extractTopLevelKeyOrder(const QString &rawJson); + +// 对 JSON 字符串值进行整体缩进(每行前加 4 空格) +QString indentJsonValue(const QString &json); + +// JSON 字符串值转义(\\\\ " \n \r \t), 供格式化函数与页面手拼 JSON 片段共用; +// 各处独立实现易漏转义字符(如只转 \\ " 不转换行)导致保存后 JSON 非法 +QString escapeJsonString(const QString &value); + +// 通用 JSON 值格式化: 对象/数组走 QJsonDocument Indented 再整体缩进, +// 字符串手工转义, 其余按 bool/number/null 输出 +QString formatJsonValue(const QJsonValue &value); + +// 按顺序格式化 string->string 键值对对象(条目 8 空格缩进) +QString formatOrderedJsonObject(const QList> &pairs); + +// 按顺序格式化字符串数组(条目 8 空格缩进, 空数组输出 []) +QString formatOrderedJsonArray(const QStringList &items); + +// 原子写 cust.json(QSaveFile): 按 keyOrder 顺序输出顶层键, +// overrideRaw 中的键用页面自定义的格式化文本重写(如有序对象/数组), 其余键走 formatJsonValue +bool writeCustJsonOrdered(const QString &jsonPath, + const QJsonObject &json, + const QStringList &keyOrder, + const QHash &overrideRaw); + +} // namespace CustJsonIo + +#endif // CUSTJSONIO_H diff --git a/src/utils/datacache.cpp b/src/utils/datacache.cpp index 2a58ef9..491d749 100644 --- a/src/utils/datacache.cpp +++ b/src/utils/datacache.cpp @@ -13,18 +13,32 @@ #include #include #include +#include +#include DataCache::DataCache(QObject *parent) : QObject(parent), m_dataLoaded(false) { + // 预热完成 -> 主线程广播 functionCacheReady。QFutureWatcher::finished 保证仅在 + // 结果可取(resultCount>0)之后发出; 若在工作线程 lambda 内 emit, 信号排队到达时 + // 结果可能尚未落 future, 处理端接管会扑空 + connect(&m_uf20WarmupWatcher, &QFutureWatcher>::finished, + this, [this] { emit functionCacheReady(Constants::Cache::Uf20DbName); }); + connect(&m_uft3WarmupWatcher, &QFutureWatcher>::finished, + this, [this] { emit functionCacheReady(Constants::Cache::Uft3DbName); }); } DataCache::~DataCache() { - QMutexLocker locker(&m_connMutex); - for (auto& c : m_uf20Db) { if (c.isOpen()) c.close(); } - for (auto& c : m_uft3Db) { if (c.isOpen()) c.close(); } - m_uf20Db.clear(); - m_uft3Db.clear(); + // 单例故意泄漏(instance() 裸 new 永不析构), 本析构仅为完整性保留: + // 各线程的缓存连接由 QThreadStorage 在线程退出时经 ScopedConnection 析构自动清理 +} + +// 线程本地连接的生命周期守护: 线程退出时 QThreadStorage 析构本地对象, 此处按名字移除注册表连接 +DataCache::ScopedConnection::~ScopedConnection() +{ + if (!connName.isEmpty()) { + QSqlDatabase::removeDatabase(connName); + } } DataCache* DataCache::instance() @@ -39,19 +53,17 @@ DataCache* DataCache::instance() // createIfMissing=false 时(只读探测路径): 库文件不存在则不创建文件, 返回无效连接。 QSqlDatabase DataCache::database(const QString& db, bool createIfMissing) const { - const bool isUft3 = (db == "uft3"); - QHash& conns = isUft3 ? m_uft3Db : m_uf20Db; - + const bool isUft3 = (db == Constants::Cache::Uft3DbName); // QSqlDatabase 连接只能在创建它的线程中使用。元数据更新在 QtConcurrent 后台线程写库, // 功能查询在主线程读库, 因此按线程各自维护一条连接, 避免跨线程使用导致写入静默失败。 - QThread* self = QThread::currentThread(); - { - // QHash 非线程安全, 主线程查询与后台线程落库会并发访问, 需加锁保护。 - // QSqlDatabase 为值语义, 取出副本后即可解锁, 后续 SQL 不持锁。 - QMutexLocker locker(&m_connMutex); - auto it = conns.constFind(self); - if (it != conns.constEnd() && it.value().isOpen()) { - return it.value(); + // 用 QThreadStorage(线程本地存储)代替 QThread* 地址到连接的映射: QtConcurrent 空闲线程 + // 约 30s 回收, 地址复用会让新线程拿到已死线程的脏连接(写入静默失败)且注册表条目泄漏; + // 线程本地连接随线程退出经 ScopedConnection 析构自动 removeDatabase, 无需加锁手动清理 + QThreadStorage>& tls = isUft3 ? m_uft3Conn : m_uf20Conn; + if (tls.hasLocalData()) { + QSqlDatabase cached = QSqlDatabase::database(tls.localData()->connName, false); + if (cached.isValid() && cached.isOpen()) { + return cached; } } @@ -66,10 +78,12 @@ QSqlDatabase DataCache::database(const QString& db, bool createIfMissing) const LogManager::instance()->logError(QString("数据缓存 - 创建缓存目录失败: %1").arg(fi.absolutePath())); return QSqlDatabase(); } - // 连接名需全局唯一: 库名 + 线程地址 + // 连接名需全局唯一: 每线程每库仅建一条连接, 用原子计数生成唯一后缀, + // 不再依赖 QThread* 地址(线程回收复用后会与旧连接名碰撞) + static std::atomic connSeq{0}; QString connName = QString("%1_cache_connection_%2") - .arg(isUft3 ? "uft3" : "uf20") - .arg(reinterpret_cast(self), 0, 16); + .arg(isUft3 ? Constants::Cache::Uft3DbName : Constants::Cache::Uf20DbName) + .arg(connSeq.fetch_add(1)); QSqlDatabase conn = QSqlDatabase::addDatabase("QSQLITE", connName); conn.setDatabaseName(path); if (!conn.open()) { @@ -82,15 +96,25 @@ QSqlDatabase DataCache::database(const QString& db, bool createIfMissing) const return QSqlDatabase(); } // 提升 SQLite 并发写入容忍度: 后台线程落库与主线程读库并发时, - // 默认约 5s 的 busy timeout 可能不够, 统一放宽到 30s + // 默认约 5s 的 busy timeout 可能不够, 统一放宽到 Constants::Cache::BusyTimeoutMs QSqlQuery pragmaQuery(conn); - pragmaQuery.exec("PRAGMA busy_timeout = 30000"); - { - QMutexLocker locker(&m_connMutex); - conns.insert(self, conn); + if (!pragmaQuery.exec(QString("PRAGMA busy_timeout = %1").arg(Constants::Cache::BusyTimeoutMs))) { + LogManager::instance()->logWarning(QString("数据缓存 - 设置 %1 busy_timeout 失败: %2") + .arg(db, pragmaQuery.lastError().text())); } - LogManager::instance()->logInfo(QString("数据缓存 - 已打开 %1 缓存库(线程 0x%2): %3") - .arg(db).arg(reinterpret_cast(self), 0, 16).arg(path)); + // TLS 只存连接的 shared_ptr 不存值对象: QThreadStorage 值语义 setLocalData 会把传入对象 + // 拷贝一份存入 TLS, 传入的临时对象随后析构——若直接存 ScopedConnection, 临时析构即触发 + // removeDatabase, 连接在返回调用方之前就被移除并 disable(driver 被删, isOpen 变 false, + // 所有查询静默失败); 存 shared_ptr 后临时析构仅减引用计数, 线程退出时 TLS 副本析构 + // (引用计数归零)才按名字统一 removeDatabase, 时机正确 + // 注意: 不可写成 make_shared(ScopedConnection{connName})—— + // ScopedConnection 有自定义析构函数, 不再生成隐式移动构造, 传参退化为拷贝构造, + // 临时对象析构时 connName 仍在, 依然会在返回前触发 removeDatabase(已用最小实验复现), + // 必须默认构造堆对象后就地赋名, 全程不产生带名字的临时对象 + tls.setLocalData(std::make_shared()); + tls.localData()->connName = connName; + LogManager::instance()->logInfo(QString("数据缓存 - 已打开 %1 缓存库(连接 %2): %3") + .arg(db, connName, path)); return conn; } @@ -103,8 +127,8 @@ bool DataCache::initDatabase() bool ok3 = QFile::exists(Constants::uft3CacheDb()); if (ok20 || ok3) { // 仅打开已存在的库文件, 避免为尚未生成的库创建空 DB 文件 - if (ok20) database("uf20"); - if (ok3) database("uft3"); + if (ok20) database(Constants::Cache::Uf20DbName); + if (ok3) database(Constants::Cache::Uft3DbName); LogManager::instance()->log(QString("数据缓存 - 已加载缓存库: uf20=%1, uft3=%2") .arg(ok20 ? "是" : "否").arg(ok3 ? "是" : "否")); return true; @@ -162,33 +186,16 @@ QMap DataCache::getAllRows(const QString& db, const QString& t return result; } -QString DataCache::getRowData(const QString& db, const QString& table, const QString& key) const +bool DataCache::checkRowExists(const QString& db, const QString& table, const QString& name) const { QSqlDatabase conn = database(db, /*createIfMissing=*/false); if (!conn.isOpen()) { - return QString(); + return false; } QSqlQuery query(conn); query.prepare(QString("SELECT data FROM %1 WHERE name = :k").arg(table)); - query.bindValue(":k", key); - if (query.exec() && query.next()) { - return query.value(0).toString(); - } - return QString(); -} - -int DataCache::rowCount(const QString& db, const QString& table) const -{ - QSqlDatabase conn = database(db, /*createIfMissing=*/false); - if (!conn.isOpen()) { - return 0; - } - QSqlQuery query(conn); - query.prepare(QString("SELECT COUNT(*) FROM %1").arg(table)); - if (query.exec() && query.next()) { - return query.value(0).toInt(); - } - return 0; + query.bindValue(":k", name); + return query.exec() && query.next(); } double DataCache::getMetaSrcMtime(const QString& db, const QString& table) const @@ -207,20 +214,38 @@ double DataCache::getMetaSrcMtime(const QString& db, const QString& table) const return -1; } +QJsonArray DataCache::getMetaSrcMtimes(const QString& db, const QString& table) const +{ + QSqlDatabase conn = database(db, /*createIfMissing=*/false); + if (!conn.isOpen()) { + return QJsonArray(); + } + // _meta 表不存在 / 老版本库无 src_mtimes 列 / 该行值为 NULL 时 exec 报错或取空, + // 统一按"无逐目录记录"处理返回空数组(调用方回退按 src_mtime 总和比较) + QSqlQuery query(conn); + query.prepare("SELECT src_mtimes FROM _meta WHERE dataset = :t"); + query.bindValue(":t", table); + if (query.exec() && query.next()) { + const QString json = query.value(0).toString(); + if (!json.isEmpty()) { + return QJsonDocument::fromJson(json.toUtf8()).array(); + } + } + return QJsonArray(); +} + // ---- 通用写入: 原子落库到双库对应表(对齐 Python store.save) ---- // 步骤: 建临时表 t__new -> 写入 -> DROP 旧表 -> RENAME 临时表 -> 更新 _meta -// 全程持有 m_writeGate 写闸门: clearCache 以 tryLock 检测在途落库任务, 在途时跳过连接重置, -// 避免本函数写入过程中连接被 clearCache 关闭(参见 clearCache) +// cancelFlag 非空时逐行 INSERT 期间响应协作式取消: 回滚事务并返回 false(旧表数据不变) bool DataCache::saveDataset(const QString& db, const QString& table, - const QMap& rows, double srcMtime) + const QMap& rows, double srcMtime, + const QJsonArray& srcMtimes, const std::atomic_bool* cancelFlag) { QSqlDatabase conn = database(db); if (!conn.isOpen()) { LogManager::instance()->logError(QString("数据缓存 - 写入 %1.%2 失败: 库未打开").arg(db, table)); return false; } - // 取闸门放在 database() 之后: m_connMutex 短暂持锁即释放, 与 clearCache 不构成死锁环 - QMutexLocker gate(&m_writeGate); QString tmpTable = "t_" + table + "_new"; @@ -247,6 +272,15 @@ bool DataCache::saveDataset(const QString& db, const QString& table, // 按索引赋值才是幂等的, 避免残留旧绑定值。 q.prepare(QString("INSERT OR REPLACE INTO %1 (name, data) VALUES (?, ?)").arg(tmpTable)); for (auto it = rows.begin(); it != rows.end(); ++it) { + // 协作式取消检查点: 大数据集逐行 INSERT 可达数秒~数十秒, 页面析构等待后台任务 + // 退出期间若无检查点, GUI 冻结时长与落库耗时相同。事务内取消直接回滚, + // 旧表数据不受影响, 下次更新会重新落全量 + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) { + conn.rollback(); + LogManager::instance()->logWarning(QString("数据缓存 - 写入 %1.%2 已取消, 事务已回滚") + .arg(db, table)); + return false; + } q.bindValue(0, it.key()); q.bindValue(1, it.value()); if (!q.exec()) { @@ -263,12 +297,43 @@ bool DataCache::saveDataset(const QString& db, const QString& table, .arg(table, q.lastError().text())); return false; } - // 更新 _meta - q.exec("CREATE TABLE IF NOT EXISTS _meta (dataset TEXT PRIMARY KEY, row_count INTEGER, src_mtime REAL)"); - q.prepare("INSERT OR REPLACE INTO _meta (dataset, row_count, src_mtime) VALUES (?, ?, ?)"); + // 更新 _meta(建表失败不静默: 后续补列探测与 _meta INSERT 必然失败, 直接回滚并给出根因) + if (!q.exec("CREATE TABLE IF NOT EXISTS _meta (dataset TEXT PRIMARY KEY, row_count INTEGER, src_mtime REAL)")) { + conn.rollback(); + LogManager::instance()->logError(QString("数据缓存 - %1 创建 _meta 表失败: %2") + .arg(db, q.lastError().text())); + return false; + } + // 老版本库的 _meta 无 src_mtimes 列(逐目录 mtime JSON 数组), 检测后补列: + // mtime 跨目录相加可相互抵消导致"无变化"误判, 多源数据集需逐目录记录比较 + { + QSqlQuery mi(conn); + if (mi.exec("PRAGMA table_info(_meta)")) { + bool hasSrcMtimesCol = false; + while (mi.next()) { + if (mi.value(1).toString() == "src_mtimes") { + hasSrcMtimesCol = true; + break; + } + } + if (!hasSrcMtimesCol) { + // 补列失败不静默: 下游 4 列 INSERT 必然失败, 不检查会让错误被误报为 + // "更新 _meta 失败", 排障方向被误导(真正原因是老库结构迁移失败) + if (!mi.exec("ALTER TABLE _meta ADD COLUMN src_mtimes TEXT")) { + conn.rollback(); + LogManager::instance()->logError(QString("数据缓存 - %1 _meta 补列 src_mtimes 失败: %2") + .arg(db, mi.lastError().text())); + return false; + } + } + } + } + q.prepare("INSERT OR REPLACE INTO _meta (dataset, row_count, src_mtime, src_mtimes) VALUES (?, ?, ?, ?)"); q.bindValue(0, table); q.bindValue(1, rows.size()); q.bindValue(2, srcMtime); + // src_mtimes 为空(未改造的数据集)时绑空串, 老库该列为 NULL 的语义不变 + q.bindValue(3, QJsonDocument(srcMtimes).toJson(QJsonDocument::Compact)); if (!q.exec()) { conn.rollback(); LogManager::instance()->logError(QString("数据缓存 - 更新 _meta 失败: %1").arg(q.lastError().text())); @@ -284,19 +349,26 @@ bool DataCache::saveDataset(const QString& db, const QString& table, .arg(db, table).arg(rows.size())); // 修复前的历史库文件已膨胀(空闲页约占一半): 检测到空闲页过大时执行一次 VACUUM 回收磁盘。 // VACUUM 不能在事务内执行, 故放在 commit 成功之后。 + // VACUUM 耗时较长(大库可达数秒), 取消时跳过: 仅磁盘空间回收, 不影响数据正确性 + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) { + return true; + } QSqlQuery v(conn); if (v.exec("PRAGMA freelist_count") && v.next()) { // 空闲页字节数 = 空闲页数 * 实际 page_size(建库时可自定义, 不应硬编码 4096) - qlonglong pageSize = 4096; + qlonglong pageSize = Constants::Cache::DefaultPageSize; QSqlQuery ps(conn); if (ps.exec("PRAGMA page_size") && ps.next()) { pageSize = ps.value(0).toLongLong(); } const qlonglong freeBytes = v.value(0).toLongLong() * pageSize; - if (freeBytes > 64 * 1024 * 1024) { + if (freeBytes > Constants::Cache::VacuumThresholdBytes) { LogManager::instance()->logInfo(QString("数据缓存 - %1 空闲页过大(%2 MB), 执行 VACUUM 回收磁盘空间...") .arg(db).arg(freeBytes / (1024 * 1024))); - v.exec("VACUUM"); + if (!v.exec("VACUUM")) { + LogManager::instance()->logError(QString("数据缓存 - %1 VACUUM 回收失败: %2") + .arg(db, v.lastError().text())); + } } } // 注意: 此处不写功能查询内存缓存失效标志(m_*FuncCacheValid)——本函数运行在 @@ -305,18 +377,17 @@ bool DataCache::saveDataset(const QString& db, const QString& table, return true; } + // ---- 是否已缓存 ---- -bool DataCache::hasUF20Config(const QString& fileName) +bool DataCache::hasUF20Config() { - Q_UNUSED(fileName); - return datasetExists("uf20", Constants::Cache::Uf20InterfaceTable); + return datasetExists(Constants::Cache::Uf20DbName, Constants::Cache::Uf20InterfaceTable); } -bool DataCache::hasUFT3Config(const QString& fileName) +bool DataCache::hasUFT3Config() { - Q_UNUSED(fileName); - return datasetExists("uft3", Constants::Cache::Uft3InterfaceTable); + return datasetExists(Constants::Cache::Uft3DbName, Constants::Cache::Uft3InterfaceTable); } // ---- 功能查询: 从 *interface 表读取 data(JSON) 提取 cname/eName/functionNo ---- @@ -357,11 +428,11 @@ namespace { const QString& field, bool exactMatch) { const Qt::CaseSensitivity cs = exactMatch ? Qt::CaseSensitive : Qt::CaseInsensitive; - if (field == "cname") { + if (field == Constants::FunctionSearch::FieldCName) { return exactMatch ? (info.cname == keyword) : info.cname.contains(keyword, cs); - } else if (field == "function_no") { + } else if (field == Constants::FunctionSearch::FieldFunctionNo) { return exactMatch ? (info.functionNo == keyword) : info.functionNo.contains(keyword, cs); - } else if (field == "eName") { + } else if (field == Constants::FunctionSearch::FieldEName) { return exactMatch ? (info.eName == keyword) : info.eName.contains(keyword, cs); } return info.cname.contains(keyword, cs) || @@ -422,12 +493,48 @@ namespace { } } +// ---- UF20/UFT3 成对方法的通用实现(仅库名/表名不同, 公共方法一行转发) ---- + +bool DataCache::checkCnameExists(const QString& db, const QString& table, const QString& cname) +{ + // 只读探测: 缓存库不存在时不创建(与 datasetExists/getAllRows/checkRowExists 口径一致), + // 避免未做过元数据更新的机器被兜底查询意外创建 0KB 空库 + QSqlDatabase conn = database(db, false); + if (!conn.isOpen()) { + return false; + } + QSqlQuery query(conn); + query.prepare(QString("SELECT data FROM %1 WHERE name = :k").arg(table)); + query.bindValue(":k", cname); + return query.exec() && query.next(); +} + +QSet DataCache::getAllCnames(const QString& db, const QString& logTag) +{ + QSet result; + const QList& all = cachedFunctions(db); + for (const FunctionInfo& info : all) { + if (!info.cname.isEmpty()) { + result.insert(info.cname); + } + } + LogManager::instance()->logInfo(QString("数据缓存 - 获取%1 Cname缓存 %2 条").arg(logTag).arg(result.size())); + return result; +} + +QList DataCache::searchFunctions(const QString& db, const QStringList& keywords, + const QString& field, bool exactMatch, int* keywordsWithoutMatch) +{ + // 公共过滤实现见匿名命名空间 filterFunctionInfos + return filterFunctionInfos(cachedFunctions(db), keywords, field, exactMatch, keywordsWithoutMatch); +} + // 构建指定库的全量 FunctionInfo 列表(全表扫描+JSON 解析)。 // 可在任意线程调用: getAllRows 按线程建立专用连接, 与主线程查询互不阻塞。 QList DataCache::buildFunctionCache(const QString& db, bool* ok) const { if (ok) *ok = false; - const bool isUf20 = (db == "uf20"); + const bool isUf20 = (db == Constants::Cache::Uf20DbName); QList cache; if (isUf20) { // UF20 功能号全部落在 uf2_interface 一张表 @@ -446,7 +553,7 @@ QList DataCache::buildFunctionCache(const QString& db, bool* ok) c tables << Constants::Cache::Uft3InterfaceTable << Constants::Cache::TblUft3TransCode; for (const QString& t : tables) { - // uft3_trans_code 仅在交易码数据非空时由 saveDataset 创建(saveRowsFromJson + // uft3_trans_code 仅在交易码数据非空时由 saveDataset 创建(saveRowsToDb // 对空数据不落库), 工程无交易码时缺表是合法状态, 先探测避免缓存构建每次失败 if (!datasetExists(db, t)) { LogManager::instance()->logInfo( @@ -473,7 +580,7 @@ QList DataCache::buildFunctionCache(const QString& db, bool* ok) c // 避免每次 search* / getAll*Cnames 都全表扫描 + 重解析所有 JSON 行。 const QList& DataCache::cachedFunctions(const QString& db) const { - bool isUf20 = (db == "uf20"); + bool isUf20 = (db == Constants::Cache::Uf20DbName); QList& cache = isUf20 ? m_uf20FuncCache : m_uft3FuncCache; bool& valid = isUf20 ? m_uf20FuncCacheValid : m_uft3FuncCacheValid; if (valid) { @@ -487,19 +594,29 @@ const QList& DataCache::cachedFunctions(const QString& db) const QFuture>& warmup = isUf20 ? m_uf20WarmupFuture : m_uft3WarmupFuture; if (warmup.isRunning()) { // 有界等待: 预热为全表扫描+JSON 解析, 大库可达数秒, 无界 waitForFinished 会冻结 UI。 - // 轮询间隙处理输入以外的事件(绘制/定时器), 保持界面响应; 超限后转主线程同步重建。 + // Qt 6.6 的 QFuture 无带超时参数的 waitForFinished, 只能轮询; 轮询间隙必须 + // msleep 让出 CPU——processEvents 的第二参是"最多处理事件的时间", 事件队列 + // 空闲时立即返回, 不加 sleep 会让主线程在等待期 100% 自旋空转。 + // sleep 间穿插处理绘制类事件保持界面响应; 超限后转主线程同步重建。 QElapsedTimer waitTimer; waitTimer.start(); - while (warmup.isRunning() && waitTimer.elapsed() < 5000) { - // Qt 6.6 的 QFuture 无带超时参数的 waitForFinished, 用 processEvents 等待间隙让线程推进 - QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 50); + while (warmup.isRunning() && waitTimer.elapsed() < Constants::Timings::CacheWarmupWaitMs) { + QThread::msleep(10); + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); } if (warmup.isRunning()) { LogManager::instance()->logWarning( - QString("数据缓存 - %1 功能预热未在限期(5s)内完成, 转为主线程同步重建").arg(db)); + QString("数据缓存 - %1 功能预热未在限期(%2ms)内完成, 转为主线程同步重建") + .arg(db).arg(Constants::Timings::CacheWarmupWaitMs)); } } - if (warmup.isValid() && warmup.resultCount() > 0) { + // 接管条件必须判"结果非空"而非 resultCount()>0: QtConcurrent::run 的 future + // 完成后 resultCount 恒为 1(结果是一个 QList 容器本身, 与元素个数无关), + // 按旧条件表不存在/查询失败导致的空结果也会被接管并把 valid 误置 true, + // 空缓存被长期固化后存在列/功能搜索永远拿不到数据(表现为缺表场景状态永不刷新)。 + // 空结果不接管则保持"未就绪", 后续查询自动重试; 快速失败路径(缺表/无库) + // 重试开销近乎为零, 不构成反复扫描 + if (!warmup.isRunning() && warmup.isValid() && !warmup.result().isEmpty()) { cache = warmup.result(); valid = true; LogManager::instance()->logInfo(QString("数据缓存 - 接管 %1 后台预热功能缓存 %2 条") @@ -526,9 +643,11 @@ const QList& DataCache::cachedFunctions(const QString& db) const bool DataCache::warmupFunctionCacheAsync(const QString& db) { - const bool isUf20 = (db == "uf20"); + const bool isUf20 = (db == Constants::Cache::Uf20DbName); + QList& cache = isUf20 ? m_uf20FuncCache : m_uft3FuncCache; bool& valid = isUf20 ? m_uf20FuncCacheValid : m_uft3FuncCacheValid; QFuture>& warmup = isUf20 ? m_uf20WarmupFuture : m_uft3WarmupFuture; + QFutureWatcher>& watcher = isUf20 ? m_uf20WarmupWatcher : m_uft3WarmupWatcher; if (valid) { return true; // 已就绪 @@ -536,6 +655,21 @@ bool DataCache::warmupFunctionCacheAsync(const QString& db) if (warmup.isRunning()) { return false; // 已在后台预热中 } + // 上一次预热已完成但其结果尚未被 cachedFunctions 接管(valid 仍为 false): + // 此处直接接管并置有效, 对齐 cachedFunctions 的接管逻辑。否则预热完成后 + // valid 不会自行翻转, cname 集合路径(doLoadCnameCache -> warmupCache)永远拿不到 + // 就绪状态, 只能等用户先进入功能查询触发一次 search——表现为"元数据更新/启动 + // 预热完成后, 转码工作台存在列迟迟不刷新, 进过 UF20 功能查询才刷新"。 + // 条件判"结果非空"而非 resultCount()>0(完成后恒为 1): 空结果(缺表/查询失败) + // 不接管, 保持未就绪交由后续查询重试, 见 cachedFunctions 内同名注释 + // (接管读写均在主线程, 与 cachedFunctions 无并发) + if (warmup.isValid() && !warmup.result().isEmpty()) { + cache = warmup.result(); + valid = true; + LogManager::instance()->logInfo(QString("数据缓存 - 接管 %1 已完成预热功能缓存 %2 条") + .arg(db).arg(cache.size())); + return true; + } warmup = QtConcurrent::run([this, db]() -> QList { QElapsedTimer timer; @@ -545,6 +679,8 @@ bool DataCache::warmupFunctionCacheAsync(const QString& db) .arg(db).arg(list.size()).arg(timer.elapsed())); return list; }); + // 挂接监视器: 完成后于主线程广播 functionCacheReady(转码工作台借此补刷存在列) + watcher.setFuture(warmup); return false; } @@ -554,132 +690,63 @@ void DataCache::refreshFunctionCache() // 旧结果因 future 被覆盖无人接管自然丢弃, 不会被 cachedFunctions 误接管) m_uf20WarmupFuture = QFuture>(); m_uft3WarmupFuture = QFuture>(); + // 同步摘除 watcher 对旧任务的挂接: 旧任务完成时不再广播过期就绪事件。 + // 即便仍有过期事件被投递, 处理端也会因缓存未就绪而安全跳过, 此处属双保险 + m_uf20WarmupWatcher.setFuture(QFuture>()); + m_uft3WarmupWatcher.setFuture(QFuture>()); m_uf20FuncCacheValid = false; m_uft3FuncCacheValid = false; // 触发后台重建, 不阻塞主线程; 后续查询在预热完成前按原全表路径兜底 - warmupFunctionCacheAsync("uf20"); - warmupFunctionCacheAsync("uft3"); + warmupFunctionCacheAsync(Constants::Cache::Uf20DbName); + warmupFunctionCacheAsync(Constants::Cache::Uft3DbName); LogManager::instance()->logInfo("数据缓存 - 功能查询缓存已触发后台重建"); } bool DataCache::checkUF20CnameExists(const QString& cname) { - QSqlDatabase conn = database("uf20"); - if (!conn.isOpen()) { - return false; - } - QSqlQuery query(conn); - query.prepare(QString("SELECT data FROM %1 WHERE name = :k").arg(Constants::Cache::Uf20InterfaceTable)); - query.bindValue(":k", cname); - return query.exec() && query.next(); + // 只读探测: 缓存库不存在时不创建(与 datasetExists/getAllRows/checkRowExists 口径一致), + // 避免未做过元数据更新的机器被兜底查询意外创建 0KB 空库 + return checkCnameExists(Constants::Cache::Uf20DbName, Constants::Cache::Uf20InterfaceTable, cname); } QSet DataCache::getAllUF20Cnames() { - QSet result; - const QList& all = cachedFunctions("uf20"); - for (const FunctionInfo& info : all) { - if (!info.cname.isEmpty()) { - result.insert(info.cname); - } - } - LogManager::instance()->logInfo(QString("数据缓存 - 获取UF20 Cname缓存 %1 条").arg(result.size())); - return result; + return getAllCnames(Constants::Cache::Uf20DbName, QStringLiteral("UF20")); } QList DataCache::searchUF20Functions(const QString& keyword, const QString& field, bool exactMatch) { - // 公共过滤实现与 UFT3 共用(见匿名命名空间 filterFunctionInfos) - return filterFunctionInfos(cachedFunctions("uf20"), QStringList{ keyword }, field, exactMatch); + // 公共过滤实现见匿名命名空间 filterFunctionInfos + return searchFunctions(Constants::Cache::Uf20DbName, QStringList{ keyword }, field, exactMatch, nullptr); } QList DataCache::searchUF20Functions(const QStringList& keywords, const QString& field, bool exactMatch, int* keywordsWithoutMatch) { - return filterFunctionInfos(cachedFunctions("uf20"), keywords, field, exactMatch, keywordsWithoutMatch); + return searchFunctions(Constants::Cache::Uf20DbName, keywords, field, exactMatch, keywordsWithoutMatch); } bool DataCache::checkUFT3CnameExists(const QString& cname) { - QSqlDatabase conn = database("uft3"); - if (!conn.isOpen()) { - return false; - } - QSqlQuery query(conn); - query.prepare(QString("SELECT data FROM %1 WHERE name = :k").arg(Constants::Cache::Uft3InterfaceTable)); - query.bindValue(":k", cname); - return query.exec() && query.next(); + // 只读探测: 缓存库不存在时不创建(与 datasetExists/getAllRows/checkRowExists 口径一致), + // 避免未做过元数据更新的机器被兜底查询意外创建 0KB 空库 + return checkCnameExists(Constants::Cache::Uft3DbName, Constants::Cache::Uft3InterfaceTable, cname); } QSet DataCache::getAllUFT3Cnames() { - QSet result; - const QList& all = cachedFunctions("uft3"); - for (const FunctionInfo& info : all) { - if (!info.cname.isEmpty()) { - result.insert(info.cname); - } - } - LogManager::instance()->logInfo(QString("数据缓存 - 获取UFT3 Cname缓存 %1 条").arg(result.size())); - return result; + return getAllCnames(Constants::Cache::Uft3DbName, QStringLiteral("UFT3")); } QList DataCache::searchUFT3Functions(const QString& keyword, const QString& field, bool exactMatch) { // 公共过滤实现与 UF20 共用(见匿名命名空间 filterFunctionInfos) - return filterFunctionInfos(cachedFunctions("uft3"), QStringList{ keyword }, field, exactMatch); + return searchFunctions(Constants::Cache::Uft3DbName, QStringList{ keyword }, field, exactMatch, nullptr); } QList DataCache::searchUFT3Functions(const QStringList& keywords, const QString& field, bool exactMatch, int* keywordsWithoutMatch) { - return filterFunctionInfos(cachedFunctions("uft3"), keywords, field, exactMatch, keywordsWithoutMatch); -} - -void DataCache::clearCache() -{ - // 等待可能在途的后台预热任务完成, 避免关闭其正在使用的数据库连接 - m_uf20WarmupFuture.waitForFinished(); - m_uft3WarmupFuture.waitForFinished(); - m_uf20WarmupFuture = QFuture>(); - m_uft3WarmupFuture = QFuture>(); - - // 写闸门检测在途落库任务(saveDataset 全程持闸门): 在途时不能关闭其正在使用的连接, - // 此时仅重置内存状态并跳过连接重置(落库完成后由调用方再次触发即可) - if (!m_writeGate.tryLock()) { - LogManager::instance()->logWarning("数据缓存 - 检测到落库任务在途, 本次仅重置内存状态, 跳过连接重置"); - m_dataLoaded = false; - m_uf20FuncCacheValid = false; - m_uft3FuncCacheValid = false; - return; - } - // 持有闸门执行连接清理: 检测通过后到连接关闭之间不会再有新落库任务开闸。 - // 清理代码无提前返回, 闸门手动释放于函数末尾(与 m_connMutex 释放顺序无关) - QMutexLocker locker(&m_connMutex); - QStringList names; - for (auto it = m_uf20Db.begin(); it != m_uf20Db.end(); ++it) { - if (it.value().isOpen()) it.value().close(); - names << it.value().connectionName(); - } - m_uf20Db.clear(); - for (const QString& n : names) { - QSqlDatabase::removeDatabase(n); - } - names.clear(); - for (auto it = m_uft3Db.begin(); it != m_uft3Db.end(); ++it) { - if (it.value().isOpen()) it.value().close(); - names << it.value().connectionName(); - } - m_uft3Db.clear(); - for (const QString& n : names) { - QSqlDatabase::removeDatabase(n); - } - m_dataLoaded = false; - // 失效功能查询内存缓存, 重新打开后首次查询自动重建 - m_uf20FuncCacheValid = false; - m_uft3FuncCacheValid = false; - LogManager::instance()->logInfo("数据缓存 - 已重置缓存连接(缓存库文件保留)"); - // m_connMutex 已随 QMutexLocker 析构释放, 此处释放写闸门 - m_writeGate.unlock(); + return searchFunctions(Constants::Cache::Uft3DbName, keywords, field, exactMatch, keywordsWithoutMatch); } diff --git a/src/utils/datacache.h b/src/utils/datacache.h index 3e38841..c32373c 100644 --- a/src/utils/datacache.h +++ b/src/utils/datacache.h @@ -7,11 +7,11 @@ #include #include #include -#include -#include -#include -#include +#include #include +#include +#include +#include struct FunctionInfo { QString cname; @@ -43,26 +43,34 @@ public: // 读取整张键值表: name -> data(JSON 字符串)。 // ok 出参: 查询成功(含空表)为 true; 库未打开/SQL 执行失败为 false, 供调用方区分"空表"与"查询失败" QMap getAllRows(const QString& db, const QString& table, bool* ok = nullptr) const; - // 读取单行 data - QString getRowData(const QString& db, const QString& table, const QString& key) const; - // 行数 - int rowCount(const QString& db, const QString& table) const; // 读取 _meta.src_mtime(上次成功落库时记录的源目录 mtime); 无记录返回 -1 // (库未打开 / _meta 表不存在 / 该表从未落库), 用于"源无变化快速跳过"判断 double getMetaSrcMtime(const QString& db, const QString& table) const; - // ---- 通用写入 API (C++ 解析源文件后落库到双库对应表) ---- - // rows: name -> data(JSON 字符串); 原子写(临时表 + RENAME)后更新 _meta - bool saveDataset(const QString& db, const QString& table, - const QMap& rows, double srcMtime = 0.0); + // 读取 _meta.src_mtimes(上次成功落库时记录的逐目录 mtime JSON 数组)。 + // 多源数据集(如 uf2_interface = uf20/ufAcct20/dbsett 三个目录)必须逐目录记录, + // mtime 跨目录相加可相互抵消(git 操作带回旧时间戳), 和值不变会误判"无变化"而漏更新。 + // 无记录 / 老版本库无该列 / 值为空 时返回空数组(调用方回退按 src_mtime 总和比较)。 + QJsonArray getMetaSrcMtimes(const QString& db, const QString& table) const; - // ---- 兼容旧调用方的占位实现 (配置由元数据解析直接落双库, 此处为空操作) ---- - bool saveUF20Config(const QJsonObject& data) { Q_UNUSED(data); return true; } - bool saveUFT3Config(const QJsonObject& data) { Q_UNUSED(data); return true; } + // 单键存在性: name 主键是否存在于指定表(轻量单行查询, 免整表读取)。 + // 供 checkExists 兜底路径覆盖 interface 之外的表(如 trans_code) + bool checkRowExists(const QString& db, const QString& table, const QString& name) const; + + // ---- 通用写入 API (C++ 解析源文件后落库到双库对应表) ---- + // rows: name -> data(JSON 字符串); 原子写(临时表 + RENAME)后更新 _meta。 + // srcMtimes 非空时逐目录 mtime 一并写入 _meta.src_mtimes(老库自动补列), + // 空(默认)时仅写 src_mtime 总和, 兼容未改造的数据集。 + // cancelFlag 非空时逐行 INSERT 期间检查协作式取消标志: 命中即回滚事务并返回 false, + // 页面析构等待后台任务退出时落库阶段可快速终止(事务回滚, 旧表数据不受影响) + bool saveDataset(const QString& db, const QString& table, + const QMap& rows, double srcMtime = 0.0, + const QJsonArray& srcMtimes = QJsonArray(), + const std::atomic_bool* cancelFlag = nullptr); // 是否已缓存对应版本配置(= 接口表非空) - bool hasUF20Config(const QString& fileName = QString()); - bool hasUFT3Config(const QString& fileName = QString()); + bool hasUF20Config(); + bool hasUFT3Config(); // ---- 功能查询 (从 *interface 表读取) ---- bool checkUF20CnameExists(const QString& cname); @@ -83,11 +91,15 @@ public: void refreshFunctionCache(); // 触发指定库功能缓存的后台预热(不阻塞调用线程)。 - // 返回 true 表示内存缓存已就绪可直接使用; false 表示预热已在后台进行, + // 返回 true 表示内存缓存已就绪可直接使用; false 表示预热已在后台进行/结果待接管, // 查询方应走单条查库兜底(用于启动期存在性检查不阻塞主线程)。 bool warmupFunctionCacheAsync(const QString& db); - void clearCache(); +signals: + // 指定库功能缓存后台预热完成(结果已可接管)。QFutureWatcher 保证仅在结果就绪后 + // 于主线程发出。依赖方(如转码工作台"是否存在"列)借此在预热完成后立即补刷, + // 无需等用户先进入功能查询页触发缓存翻转 + void functionCacheReady(const QString &db); private: explicit DataCache(QObject *parent = nullptr); @@ -108,16 +120,28 @@ private: // ok 出参: 全部表查询成功为 true; 任一表查询失败为 false(返回空列表, 避免半截缓存被误标有效) QList buildFunctionCache(const QString& db, bool* ok = nullptr) const; - // key = 线程指针, value = 该线程专用的数据库连接 - // QHash 非线程安全, 由 m_connMutex 保护(主线程查询与后台线程落库会并发访问) - mutable QHash m_uf20Db; - mutable QHash m_uft3Db; - mutable QMutex m_connMutex; - // 写闸门: saveDataset 全程持有, clearCache 以 tryLock 检测在途落库任务—— - // 在途时不能关闭其正在使用的数据库连接, 仅重置内存状态。 - // 锁序安全: saveDataset 在取闸门前已调用完 database()(m_connMutex 短暂持锁即释放), - // 不存在"持 m_connMutex 等闸门"的路径, 与 clearCache(持闸门后取 m_connMutex)不构成死锁环 - mutable QMutex m_writeGate; + // UF20/UFT3 成对方法的通用实现(仅库名/表名不同, 公共方法一行转发, 见 .cpp) + bool checkCnameExists(const QString& db, const QString& table, const QString& cname); + QSet getAllCnames(const QString& db, const QString& logTag); + QList searchFunctions(const QString& db, const QStringList& keywords, + const QString& field, bool exactMatch, int* keywordsWithoutMatch); + + // 按线程缓存的连接(QThreadStorage 线程本地存储)。连接只能在创建它的线程中使用, + // 线程退出时 ScopedConnection 析构自动 removeDatabase——避免 QtConcurrent 空闲线程 + // 回收后 QThread* 地址被系统复用时, 拿到已死线程的脏连接(写入静默失败)并泄漏注册表条目。 + // 必须经 shared_ptr 间接存储: QThreadStorage 值语义 setLocalData 会拷贝一份副本存入 TLS, + // 传入的临时对象随后析构——若直接存 ScopedConnection, 临时对象析构即触发 removeDatabase, + // 把刚建好尚未返回调用方的连接从注册表移除并 disable(driver 被删), 调用方拿到 + // isOpen()==false 的死连接(所有查询静默失败, 且同线程下次调用会再建新连接导致连接数疯涨); + // 存 shared_ptr 则临时对象析构仅减引用计数, removeDatabase 推迟到线程退出时正确发生。 + // 另注意: 本类型有自定义析构函数, 不生成隐式移动构造, 任何按值传递都会退化为拷贝构造, + // 临时对象析构时 connName 仍在并提前触发 removeDatabase——入 TLS 前不得产生带名字的临时对象 + struct ScopedConnection { + QString connName; + ~ScopedConnection(); + }; + mutable QThreadStorage> m_uf20Conn; + mutable QThreadStorage> m_uft3Conn; bool m_dataLoaded; // 功能查询内存缓存(UF20/UFT3 各一份, 落库或清缓存后失效重建) @@ -131,6 +155,12 @@ private: // 共享成员的读写仍全部发生在主线程, 无需额外加锁) mutable QFuture> m_uf20WarmupFuture; mutable QFuture> m_uft3WarmupFuture; + + // 预热完成监视器: finished 仅在结果就绪(resultCount>0)后于主线程发出。 + // 借此广播 functionCacheReady——若在工作线程 lambda 内 emit, 信号排队时 + // 结果尚未落 future, 处理端接管会扑空 + QFutureWatcher> m_uf20WarmupWatcher; + QFutureWatcher> m_uft3WarmupWatcher; }; #endif diff --git a/src/utils/jsonfilemonitor.cpp b/src/utils/jsonfilemonitor.cpp new file mode 100644 index 0000000..ca05cf0 --- /dev/null +++ b/src/utils/jsonfilemonitor.cpp @@ -0,0 +1,133 @@ +#include "jsonfilemonitor.h" +#include "constants.h" + +#include +#include +#include "logmanager.h" + +JsonFileMonitor::JsonFileMonitor(QObject *parent) + : QObject(parent) + , m_watcher(new QFileSystemWatcher(this)) + , m_reloadTimer(new QTimer(this)) + , m_lastMtime(0) + , m_isSaving(false) + , m_saveSeq(0) +{ + m_reloadTimer->setSingleShot(true); + m_reloadTimer->setInterval(Constants::Timings::JsonDebounceMs); + + connect(m_watcher, &QFileSystemWatcher::fileChanged, + this, &JsonFileMonitor::onFileChanged); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, + this, &JsonFileMonitor::onDirChanged); + connect(m_reloadTimer, &QTimer::timeout, + this, &JsonFileMonitor::onReloadTimeout); +} + +JsonFileMonitor::~JsonFileMonitor() +{ + // watcher 已由 Qt 父子关系析构,此处无需处理 +} + +void JsonFileMonitor::watch(const QString &filePath) +{ + if (m_filePath == filePath) + return; + // 移除旧目标的全部监控路径,再挂新目标 + const QStringList oldPaths = m_watcher->directories() + m_watcher->files(); + if (!oldPaths.isEmpty()) + m_watcher->removePaths(oldPaths); + m_filePath = filePath; + refreshWatch(); + m_lastMtime = currentMtime(); +} + +void JsonFileMonitor::refreshWatch() +{ + // QFileSystemWatcher 的文件句柄可能因外部重写而失效,需要补挂 + const QStringList oldFiles = m_watcher->files(); + if (!oldFiles.isEmpty()) + m_watcher->removePaths(oldFiles); + + if (m_filePath.isEmpty()) + return; + if (QFileInfo::exists(m_filePath)) { + // 挂接失败不静默: watch 失效后文件变更不再触发 changed 回调, 外部修改无法感知 + if (!m_watcher->addPath(m_filePath)) { + LogManager::instance()->logWarning(QString("文件监视挂接失败: %1").arg(m_filePath)); + } + } + const QString dir = QFileInfo(m_filePath).absolutePath(); + if (!dir.isEmpty() && !m_watcher->directories().contains(dir)) { + if (!m_watcher->addPath(dir)) { + LogManager::instance()->logWarning(QString("目录监视挂接失败: %1").arg(dir)); + } + } +} + +qint64 JsonFileMonitor::currentMtime() const +{ + if (m_filePath.isEmpty()) + return 0; + QFileInfo fi(m_filePath); + return fi.exists() ? fi.lastModified().toMSecsSinceEpoch() : 0; +} + +void JsonFileMonitor::beginSaveWindow() +{ + m_isSaving = true; + ++m_saveSeq; + // 预置基准:保存引发的 mtime 变化(<= 新基准)不应触发重载 + m_lastMtime = currentMtime(); + // 兜底复位:正常应由 endSave 关闭窗口,异常路径下 2s 后自动复位 + const quint32 seq = m_saveSeq; + QTimer::singleShot(Constants::Timings::JsonSaveWindowResetMs, this, [this, seq]() { + if (m_saveSeq == seq && m_isSaving) { + m_isSaving = false; + m_lastMtime = currentMtime(); // 以复位时的 mtime 为基准 + refreshWatch(); + } + }); +} + +void JsonFileMonitor::endSave() +{ + ++m_saveSeq; // 立即作废窗口期内挂起的事件判定 + m_isSaving = false; + // 以保存后的新 mtime 为基准(writeCustJsonOrdered 同步写完才返回), + // 否则保存后的 mtime > 旧基准会误触发一次重载 + m_lastMtime = currentMtime(); + refreshWatch(); +} + +void JsonFileMonitor::onFileChanged(const QString &path) +{ + Q_UNUSED(path); + if (m_isSaving) // 保存窗口期内:自身写入,忽略 + return; + refreshWatch(); // 文件可能被外部删除重建,补挂 + m_reloadTimer->start(); +} + +void JsonFileMonitor::onDirChanged(const QString &path) +{ + Q_UNUSED(path); + if (m_isSaving) + return; + // 目录兜底:文件被删除重建时 fileChanged 可能只报一次就失联 + refreshWatch(); + m_reloadTimer->start(); +} + +void JsonFileMonitor::onReloadTimeout() +{ + const qint64 mtime = currentMtime(); + if (mtime <= 0) { + refreshWatch(); + return; + } + if (mtime <= m_lastMtime) // <= 基准:自身写入引发的回环,不触发重载 + return; + m_lastMtime = mtime; // 只报一次:本次版本已被页面感知 + emit reloadRequested(); +} diff --git a/src/utils/jsonfilemonitor.h b/src/utils/jsonfilemonitor.h new file mode 100644 index 0000000..72147b2 --- /dev/null +++ b/src/utils/jsonfilemonitor.h @@ -0,0 +1,66 @@ +#ifndef JSONFILEMONITOR_H +#define JSONFILEMONITOR_H + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// JsonFileMonitor:JSON 配置文件外部修改监控组件(公共样板收编) +// +// 职责: +// 1. 监控指定 JSON 文件(及其所在目录兜底,防文件被删除重建后失联) +// 2. 外部修改后 300ms 防抖,单发 reloadRequested() 信号 +// 3. mtime 基准过滤:自身写入(保存)引发的 mtime 变化不触发重载 +// 4. 保存窗口语义:beginSaveWindow()/endSave() 包住保存动作, +// 窗口期内监控事件直接忽略;窗口结束后 m_saveSeq 代数立即失效 +// +// 用法(页面侧): +// m_monitor = new JsonFileMonitor(this); +// connect(m_monitor, &JsonFileMonitor::reloadRequested, this, &Page::onReload); +// m_monitor->watch(Constants::custJson()); +// 保存前:m_monitor->beginSaveWindow(); +// 保存后:m_monitor->endSave(); +// --------------------------------------------------------------------------- +class JsonFileMonitor : public QObject +{ + Q_OBJECT +public: + explicit JsonFileMonitor(QObject *parent = nullptr); + ~JsonFileMonitor() override; + + // 切换监控目标(监控旧目标时先移除全部监控路径) + void watch(const QString &filePath); + + // 补挂文件+目录监控(QFileSystemWatcher 的文件句柄可能因外部重写而失效) + void refreshWatch(); + + // 当前监控文件的 mtime(文件不存在返回 0) + qint64 currentMtime() const; + + // 保存窗口:保存前调用,窗口期内忽略监控事件并预置 mtime 基准 + void beginSaveWindow(); + + // 保存动作结束:立即提升保存代数,窗口期内事件彻底作废 + void endSave(); + +signals: + // 外部修改确认(防抖+回环过滤后)触发;页面应检查脏标志后重载 + void reloadRequested(); + +private slots: + void onFileChanged(const QString &path); + void onDirChanged(const QString &path); + void onReloadTimeout(); + +private: + QFileSystemWatcher *m_watcher; // 双监控:文件 + 所在目录 + QTimer *m_reloadTimer; // 300ms 单发防抖 + QString m_filePath; // 监控的 JSON 文件 + qint64 m_lastMtime; // mtime 基准:<= 基准的变化视为自身写入回环 + bool m_isSaving; // 保存窗口期内为 true + quint32 m_saveSeq; // 保存代数:begin/end 各 +1,使窗口内事件过期 +}; + +#endif // JSONFILEMONITOR_H diff --git a/src/utils/logmanager.cpp b/src/utils/logmanager.cpp index 6027d31..182bbd2 100644 --- a/src/utils/logmanager.cpp +++ b/src/utils/logmanager.cpp @@ -26,6 +26,7 @@ LogManager::LogManager(QObject *parent) : QObject(parent) LogManager::~LogManager() { if (m_logFile.isOpen()) { + m_stream.flush(); m_logFile.close(); } } @@ -62,11 +63,6 @@ QString LogManager::resolveLogDir() const return QCoreApplication::applicationDirPath() + "/logs"; } -QString LogManager::getLogFilePath() const -{ - return m_logFilePath; -} - void LogManager::ensureFileOpen() { // 实际文件名带当天日期前缀:YYYYMMDD-<基础名>,如 20260812-change_code.log @@ -74,27 +70,54 @@ void LogManager::ensureFileOpen() const QString filePath = QDir(resolveLogDir()) .absoluteFilePath(QString("%1-%2").arg(tag, m_baseName)); - // 跨天运行时,当前打开的文件不属于今天 -> 关闭旧文件,切换到今天的文件 + // 跨天运行时,当前打开的文件不属于今天 -> flush 后关闭旧文件,切换到今天的文件 if (m_logFile.isOpen() && m_logFilePath != filePath) { + m_stream.flush(); + m_stream.setDevice(nullptr); m_logFile.close(); m_fileBytes = 0; } if (!m_logFile.isOpen()) { + // 跨天/首跑: 路径变化时清打开失败标志, 新的一天重新尝试打开 + const bool pathChanged = (m_logFilePath != filePath); m_logFilePath = filePath; m_logFile.setFileName(filePath); - if (!m_logFile.open(QIODevice::Append | QIODevice::Text)) { - qDebug() << "无法打开日志文件:" << filePath; - } else { + if (pathChanged) { + m_openFailed = false; + } + // 打开失败: 告警一次并记标志, 同一路径不再逐条日志重试 open(逐条 syscall 开销) + if (!m_openFailed && !m_logFile.open(QIODevice::Append | QIODevice::Text)) { + m_openFailed = true; + qWarning() << "无法打开日志文件(日志写入将不可用, 请检查日志目录权限):" << filePath; + } else if (m_logFile.isOpen()) { // 追加模式打开: 以文件实际大小初始化字节缓存, 后续轮转判断不再逐条 stat m_fileBytes = m_logFile.size(); + m_stream.setDevice(&m_logFile); } } } +void LogManager::flushStream(bool force) +{ + if (!m_logFile.isOpen()) { + return; + } + // 节流落盘: 严重日志/轮转/跨天立即 flush; 普通日志攒到 8KB 或距上次 flush 超 1s, + // 避免每条日志都强制刷盘 + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (force || m_pendingBytes >= 8 * 1024 || now - m_lastFlushMs >= 1000) { + m_stream.flush(); + m_pendingBytes = 0; + m_lastFlushMs = now; + } +} + void LogManager::rotateFile() { if (m_logFile.isOpen()) { + m_stream.flush(); + m_stream.setDevice(nullptr); m_logFile.close(); } @@ -126,6 +149,13 @@ void LogManager::rotateFile() m_logFile.setFileName(m_logFilePath); if (m_logFile.open(QIODevice::Append | QIODevice::Text)) { m_fileBytes = 0; + m_stream.setDevice(&m_logFile); + } else { + // 原文件 reopen 也失败: 置打开失败标志, 交由 ensureFileOpen 的失败路径统一收敛 + // (同一路径不逐条重试 open)——否则下一条日志会再次盲目 open 并重复告警 + m_openFailed = true; + qWarning() << "日志轮转失败后原文件重新打开失败, 日志写入暂停:" << m_logFilePath + << m_logFile.errorString(); } return; } @@ -314,31 +344,6 @@ QString LogManager::captureStackTrace() } #endif -void LogManager::setLogLevel(LogLevel level) -{ - m_logLevel = level; -} - -LogLevel LogManager::logLevel() const -{ - return m_logLevel; -} - -bool LogManager::logToConsole() const -{ - return m_toConsole; -} - -bool LogManager::logToFile() const -{ - return m_toFile; -} - -QString LogManager::logDir() const -{ - return m_logDir; -} - void LogManager::writeLog(LogLevel level, const QString& message) { // 等级过滤:低于当前配置等级的日志不写入文件也不输出到控制台 @@ -384,10 +389,12 @@ void LogManager::writeLog(LogLevel level, const QString& message) rotateFile(); } if (m_logFile.isOpen()) { - QTextStream stream(&m_logFile); - stream << logLine; - stream.flush(); + // 持久 stream 复用(绑定于 ensureFileOpen), 免每条重建 QTextStream; + // Error 立即落盘, 其余按 8KB/1s 节流 + m_stream << logLine; m_fileBytes += utf8Line.size(); // 同步维护字节缓存 + m_pendingBytes += utf8Line.size(); + flushStream(level == LogLevel::Error); } } diff --git a/src/utils/logmanager.h b/src/utils/logmanager.h index 0064e49..3e150a1 100644 --- a/src/utils/logmanager.h +++ b/src/utils/logmanager.h @@ -31,13 +31,6 @@ public: // 从 change_code.ini 的 [log] 段读取完整日志配置(在 initLogFile 前调用) void initLogConfig(); - void setLogLevel(LogLevel level); - LogLevel logLevel() const; - bool logToConsole() const; - bool logToFile() const; - QString logDir() const; - - QString getLogFilePath() const; private: explicit LogManager(QObject *parent = nullptr); @@ -45,6 +38,7 @@ private: void writeLog(LogLevel level, const QString& message); void ensureFileOpen(); // 确保日志文件已打开(未打开则打开,跨天自动切换) + void flushStream(bool force); // 节流落盘(force=true 立即 flush,普通日志按 8KB/1s 节流) void rotateFile(); // 超过 maxSize 时按 maxFiles 轮转 void cleanupByAge(); // 删除超过 maxAge 天的历史日志 QString captureStackTrace(); // 调用堆栈(caller=true 时) @@ -53,6 +47,9 @@ private: QString resolveLogDir() const; // 解析日志目录(空 -> 当前目录) QFile m_logFile; + QTextStream m_stream; // 持久日志流(绑定 m_logFile,免每条重建 stream) + qint64 m_pendingBytes = 0; // 未 flush 的字节数(节流判据) + qint64 m_lastFlushMs = 0; // 上次 flush 的毫秒时间戳(节流判据) QMutex m_mutex; QString m_logFilePath; LogLevel m_logLevel = LogLevel::Info; @@ -66,6 +63,7 @@ private: int m_maxAgeDays = 30; // 保留天数,0=不限制 bool m_caller = false; // 是否附加调用堆栈 bool m_rotateWarned = false; // 轮转失败一次性告警(成功轮转后复位) + bool m_openFailed = false; // 日志文件打开失败(同一路径不逐条重试) QString m_format = "text"; // 输出格式:text / json QString m_timeFormat = "yyyy-MM-dd HH:mm:ss.zzz"; // Qt 时间格式 }; diff --git a/src/utils/uf2configreader.cpp b/src/utils/uf2configreader.cpp index 28e04bd..78e2847 100644 --- a/src/utils/uf2configreader.cpp +++ b/src/utils/uf2configreader.cpp @@ -1,5 +1,6 @@ #include "uf2configreader.h" #include "datacache.h" +#include "Constants.h" QSet UF2ConfigReader::m_cnameCache; bool UF2ConfigReader::m_cnameCacheLoaded = false; @@ -13,19 +14,15 @@ const ConfigReaderTraits& UF2ConfigReader::traits() static const ConfigReaderTraits t = [] { ConfigReaderTraits traits; traits.logTag = QStringLiteral("UF20"); - traits.mainFile = QStringLiteral("uf2.json"); - traits.reloadFiles = { qMakePair(QStringLiteral("uf2.json"), true) }; - traits.hasConfig = [](const QString& fileName) { - return DataCache::instance()->hasUF20Config(fileName); - }; - traits.saveConfig = [](const QJsonObject& config) { - return DataCache::instance()->saveUF20Config(config); + traits.mainFile = Constants::ToolFiles::Uf2Json; + traits.hasConfig = [] { + return DataCache::instance()->hasUF20Config(); }; traits.getAllCnames = [] { return DataCache::instance()->getAllUF20Cnames(); }; traits.warmupCache = [] { - return DataCache::instance()->warmupFunctionCacheAsync("uf20"); + return DataCache::instance()->warmupFunctionCacheAsync(Constants::Cache::Uf20DbName); }; traits.checkExists = [](const QString& cname) { return DataCache::instance()->checkUF20CnameExists(cname); diff --git a/src/utils/uft3configreader.cpp b/src/utils/uft3configreader.cpp index 21238a5..8187322 100644 --- a/src/utils/uft3configreader.cpp +++ b/src/utils/uft3configreader.cpp @@ -14,29 +14,22 @@ const ConfigReaderTraits& UFT3ConfigReader::traits() static const ConfigReaderTraits t = [] { ConfigReaderTraits traits; traits.logTag = QStringLiteral("UFT3"); - traits.mainFile = QStringLiteral("uft3.json"); - traits.reloadFiles = { - qMakePair(QStringLiteral("uft3.json"), false), - qMakePair(QStringLiteral("uft3Trans.json"), false) - }; - traits.hasConfig = [](const QString& fileName) { - return DataCache::instance()->hasUFT3Config(fileName); - }; - traits.saveConfig = [](const QJsonObject& config) { - return DataCache::instance()->saveUFT3Config(config); + traits.mainFile = Constants::ToolFiles::Uft3Json; + traits.hasConfig = [] { + return DataCache::instance()->hasUFT3Config(); }; traits.getAllCnames = [] { return DataCache::instance()->getAllUFT3Cnames(); }; traits.warmupCache = [] { - return DataCache::instance()->warmupFunctionCacheAsync("uft3"); + return DataCache::instance()->warmupFunctionCacheAsync(Constants::Cache::Uft3DbName); }; traits.checkExists = [](const QString& cname) { DataCache *cache = DataCache::instance(); // 与 getAllUFT3Cnames 覆盖范围保持一致: interface + trans_code 两张表 return cache->checkUFT3CnameExists(cname) - || !cache->getRowData(QStringLiteral("uft3"), - Constants::Cache::TblUft3TransCode, cname).isEmpty(); + || cache->checkRowExists(Constants::Cache::Uft3DbName, + Constants::Cache::TblUft3TransCode, cname); }; return traits; }();