diff --git a/.gitignore b/.gitignore index 3cb46d6..f5ccb3a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ !.gitignore !.gitkeep !update_info.json +!docs +!config/ diff --git a/Uft30ChangeCode.pro b/Uft30ChangeCode.pro index 14a3ab3..33bcdda 100644 --- a/Uft30ChangeCode.pro +++ b/Uft30ChangeCode.pro @@ -13,7 +13,6 @@ SOURCES += main.cpp \ src/pages/batchconvert/batchconvertpage.cpp \ src/pages/nonlsconvert/nonlsconvertpage.cpp \ src/pages/codesearch/codesearchpage.cpp \ - src/pages/functionsearch/functionsearchpage.cpp \ src/pages/functionsearch/uf20functionsearchpage.cpp \ src/pages/functionsearch/uft3functionsearchpage.cpp \ src/components/elasearchedit.cpp \ @@ -54,7 +53,6 @@ HEADERS += src/mainwindow/mainwindow.h \ src/pages/batchconvert/batchconvertpage.h \ src/pages/nonlsconvert/nonlsconvertpage.h \ src/pages/codesearch/codesearchpage.h \ - src/pages/functionsearch/functionsearchpage.h \ src/pages/functionsearch/uf20functionsearchpage.h \ src/pages/functionsearch/uft3functionsearchpage.h \ src/components/elasearchedit.h \ @@ -103,6 +101,7 @@ win32 { LIBS += -L$$PWD/3rd/ElaWidgetTools/lib -lElaWidgetTools LIBS += -ld3d11 -ldxgi LIBS += -ldbghelp # LogManager stack trace (caller) + LIBS += -ldwmapi # main.cpp 禁用 DWM 过渡动画(消除最大化首帧闪动) RC_FILE = app.rc } diff --git a/docs/使用手册.md b/docs/使用手册.md new file mode 100644 index 0000000..90cc3ca --- /dev/null +++ b/docs/使用手册.md @@ -0,0 +1,368 @@ +# Uft30ChangeCode 使用手册 + +| 文档信息 | 内容 | +| --- | --- | +| 软件名称 | Uft30ChangeCode(UF20 → UFT3 转码工具) | +| 手册版本 | V1.6.0 | +| 更新日期 | 2026-09-14 | +| 适用对象 | 使用本工具进行代码转码的开发/维护人员 | + +--- + +## 1. 软件简介 + +Uft30ChangeCode 是一款桌面端 UF20 → UFT3 代码转码工具,主要提供: + +- **业务转码**:**全量转码**(LS_ 功能清单式整体转码)与**按需转码**(非 LS 功能,支持单文件/递归两种模式)两个工作台; +- **代码检索**:查看功能的调用链路(向上调用方、向下被调),支持导出调用文件与链路文本; +- **功能查询**:检索 UF20 / UFT3 功能元数据,支持**多关键词批量查询**(逗号/空格分隔);UF20 查询结果可双击/右键**一键加入**转码清单,UFT3 仅用于查询核对; +- **数据映射**:维护 UF20 → UFT3 的表替换、函数替换对照表; +- **元数据更新**:解析 UF20/UFT3 源码,建立功能索引缓存(首次使用前必须执行)。 + +## 2. 运行环境 + +| 项目 | 要求 | +| --- | --- | +| 操作系统 | Windows 10/11(64 位) | +| 运行库 | Qt 6.6.2 运行库(随部署包提供) | +| 磁盘权限 | 对安装目录及源码目录具有读写权限 | + +## 3. 安装与启动 + +1. 将部署包解压到任意目录(建议路径不含中文与空格); +2. 确认目录下包含 `Uft30ChangeCode.exe` 与 `uf2touft3\config\` 配置目录; +3. 双击 `Uft30ChangeCode.exe` 启动,主界面左侧为功能导航栏。 + +> **首次使用必读**:请先在底部"设置"中配置 UFT30/UF20 项目环境路径(见 §5.5),再到"元数据更新"页执行一次元数据更新(见 §5.1)。否则各页面的"是否存在"判断与功能查询均不可用。 + +## 4. 界面总览 + +启动后左侧导航栏包含以下页面: + +| 导航项 | 用途 | +| --- | --- | +| 功能转码 → 业务转码 | 全量转码与按需转码工作台(页内 Tab 切换) | +| 功能转码 → 代码检索 | 功能调用链路查询与导出 | +| 功能查询 → UF20功能查询 | 检索 UF20 功能(支持批量关键词),双击/右键一键加入转码清单 | +| 功能查询 → UFT3功能查询 | 检索 UFT3 功能元数据(仅查询核对,不加入清单) | +| 数据映射 → 表替换 | 维护 UF20 → UFT3 表名映射 | +| 数据映射 → 函数替换 | 维护 UF20 → UFT3 函数名映射 | +| 元数据更新 | 解析源码、构建/刷新元数据缓存 | +| 设置(底部) | 项目环境路径与主题配置 | +| 帮助 / 关于(底部) | 在线帮助、版本信息 | + +## 5. 功能操作详解 + +### 5.1 元数据更新(首次使用必做) + +**前提**:元数据更新按"设置"页配置的项目环境路径扫描源码,请先完成 §5.5 的路径配置。 + +**操作步骤:** + +1. 左侧导航进入 **元数据更新**; +2. 页面分为 **UFT30更新范围** 与 **UF20更新范围** 两个卡片,勾选需要更新的元数据项(默认全选): + - UFT30:数据类型、标准字段、表结构、接口数据; + - UF20:数据类型、标准字段、标准错误号、接口数据、常量、菜单功能、模块、表结构; +3. 可用 **全选 / 全不选** 按钮快速调整勾选范围; +4. 点击 **更新UFT30** 或 **更新UF20**,页面显示进度与当前处理项; +5. 等待完成提示后,再使用其他功能页面。 + +**选项说明:** + +- **增量快速跳过**:增量更新先对比源码目录整体修改时间,源码无任何变化时秒级完成,不再逐文件重复扫描; +- **强制全量更新**(进度条右上角复选框):默认增量更新,仅重新处理有变化的文件;勾选后将重新扫描并解析全部接口文件、重建增量缓存,耗时较久,勾选时需二次确认; +- 源码变更后请重新执行更新,保证索引与实际代码一致; +- 更新过程在后台线程执行,界面不会卡死。 + +### 5.2 功能转码 + +#### 5.2.1 业务转码 + +业务转码页顶部通过 Tab 切换 **全量转码** / **按需转码** 两个工作台。 + +> **任务互斥**:任一 Tab 转换期间,另一 Tab 的"开始转换"按钮被禁用并提示等待,避免并发冲突。 + +##### 5.2.1.1 全量转码 + +**说明**:全量转码仅支持 **LS_ 开头**的功能("内存交易"改造范围),按清单对功能进行整体转码。清单保存在 `cust.json` 的 `convert.dir` 节点(见 §6.1)。 + +**表格列**:`UF20函数名` | `是否存在` | `操作`(行尾编辑按钮)。 + +- "是否存在"显示 **否**:说明元数据未更新或该功能在 UF20 元数据中检索不到(见 §7 FAQ); +- 每次显示页面时自动刷新"是否存在"列。 + +**操作步骤:** + +1. 切到 **全量转码** Tab; +2. 点击 **添加**,弹窗内**每行输入一个** LS_ 函数名(支持批量粘贴);输入非 LS 函数将被拒绝,并提示切换到"按需转码"; +3. 也可在 **UF20功能查询** 中双击或右键选中 LS_ 功能直接加入(见 §5.3.1); +4. 点击行尾编辑按钮可修改函数名;**删除选中** / **清空列表**(二次确认)维护清单; +5. 点击 **开始转换**,进度条与状态文字显示转换进度;转换中按钮变为 **停止转换**,可随时点击终止任务。 + +**清单同步**:手工编辑 `cust.json` 保存后约 0.3 秒内界面自动刷新(文件监视 + 防抖),程序与手工编辑双向同步。 + +##### 5.2.1.2 按需转码 + +**说明**:按需转码针对**非 LS 功能**转码,支持**单文件转码**与**递归转码**两种模式,转码范围不包含核心文件、module 模块文件。 + +- **非递归模式**:仅对目标文件本身执行转码,不会处理该文件内调用的函数; +- **递归模式**:转码目标文件的同时,自动对文件中所调用的关联函数一并完成转码。 + +**添加功能(三种方式):** + +- **方式一(推荐)**:进入 **UF20功能查询**,双击或右键目标非 LS 函数(支持多选后右键批量加入)→ 弹窗提示"已将 N 个功能加入**按需转码**"; +- **方式二**:按需转码 Tab 点击 **添加**,弹窗内每行输入一个函数短名(如 `AS_xxx`);误带 `.service_design` 等后缀会自动剥离;新增功能递归模式默认 **否**;若输入 LS_ 开头函数仅给出提示(建议放入全量转码),不会拦截; +- **方式三**:手工编辑配置文件 `cust_nonls.json`(见 §6.2),保存后程序自动检测并刷新界面。 + +**表格列**:`UF20函数名` | `是否存在` | `是否递归` | `操作`(行尾编辑按钮)。 + +- LS_ 开头的函数名以橙色字体显示提醒(不拦截)。 + +**设置递归模式:** + +1. 在目标行的 **是否递归** 下拉框选择 **否**(单文件)或 **是**(递归); +2. 选择后立即写入 `cust_nonls.json` 并生效,无需额外点击。 + +**执行转码:** 点击 **开始转换**,进度显示与停止方式同全量转码;任务互斥规则相同。 + +**清单同步**:外部编辑保存后约 0.3 秒内界面自动刷新,支持程序内修改与外部文件修改双向同步。 + +#### 5.2.2 代码检索 + +**说明**:代码检索用于查看功能的**调用链路**——该功能调用了哪些下游函数、被哪些上游功能调用,用于转码前评估影响范围、转码后核对结果。 + +页面顶部 Tab 切换 **转码业务** / **非转码业务** 两种模式,两种模式各自维护检索路径与索引,互不干扰。 + +**通用操作:** + +1. **代码检索路径**:选择需要分析的代码目录(默认 `bin\uf2touft3\change_code`),点 **浏览** 更改; +2. 首次显示页面会**自动构建索引**(优先读取缓存,秒级加载);若手动更改过路径,需点 **刷新索引** 重新加载; +3. 索引构建在后台线程执行,期间显示进度框,完成后写入缓存; +4. 每次转码任务完成后,程序会自动静默刷新索引; +5. 检索路径按模式持久化保存,下次启动自动恢复(见 §6.3)。 + +**转码业务模式:** + +- 左侧显示 **业务转码函数列表**(与全量转码清单 `cust.json` 的 `convert.dir` 同步); +- **双击**左侧函数 → 右侧树显示调用链路(向上:哪些上游功能调用了它;向下:它调用了哪些下游函数);该模式仅支持从列表双击查询,不提供手动输入检索行; +- 树列为:`函数名`、`目录`、`模块`;当前查询行**蓝色高亮**; +- **原代码已支持** 行**绿色高亮**(该函数无需转码,行前带 UFT3 功能号前缀);其他标记:`[未找到]`、`[循环调用]`、`[层级截断]`; +- 点 **导出调用文件**:将链路上所有需转码文件按原目录结构复制到 `<检索路径>\function_output\<查询函数名>\`,并生成链路文本 `_call_chain.txt`; +- 树标题行右侧 **全部展开 / 全部折叠** 按钮控制树展示层级,列宽自适应内容且可拖拽调整。 + +**非转码业务模式:** + +该模式通过页面顶部输入行检索,支持两种检索方式(下拉框切换): + +- **按函数名检索**:输入函数名(如 `LS_xxx` / `LF_xxx`,也支持带功能号前缀的 `功能号-函数名` 格式),回车或点 **查询链路** 直接查询; +- **按功能号检索**:输入 UFT3 功能号,程序先通过 UFT3 功能元数据将功能号解析为对应函数(英文名),再查询调用链路。前提是元数据已更新(见 §5.1),否则提示"功能数据未加载";功能号不存在时提示"未找到功能号对应函数"。 + +查询成功后调用链路显示在右侧树中,展示规则(列、高亮、状态标记)与转码业务模式一致。 + +- 点 **导出调用链路**:仅导出 `_call_chain.txt` 链路文本到 `<检索路径>\function_output\<查询函数名>\`(不复制源文件)。 + +### 5.3 功能查询 + +#### 5.3.1 UF20 功能查询 + +**操作步骤:** + +1. 进入 **UF20功能查询** 页; +2. 左侧下拉选择检索字段:**函数名** / **功能号**,输入关键字回车或点搜索图标; + - **批量查询**:多个关键词用**逗号、分号或空格**分隔(中英文均可),如 `LS_证券参数, LS_股票名单`;各关键词命中结果取**并集**展示; + - 单个关键词可命中同一功能的多个字段,结果按"函数名+功能号"自动去重; +3. 搜索框内可切换**精确匹配**(默认模糊匹配); +4. 查看结果列表,列:`函数名`、`英文名`、`功能号`; +5. **双击或右键选中行一键加入转码工作台**:LS_ 开头功能 → **全量转码**;其它功能 → **按需转码**; + - 支持 **Ctrl/Shift 多选**后一次加入;右键行不在选区内时仅加入该行; + - 加入结果**只弹一个提示框**("全量转码"、"按需转码"字样加粗):同时包含两类功能时显示"已将 N 个功能加入全量转码,已将 M 个功能加入按需转码";重复添加的功能自动跳过并在提示中注明;全部已存在时提示"均已在转码列表中"。 + +**查询提示**:查询反馈显示在页面底部提示栏(颜色随日间/夜间主题自动适配),不弹对话框打断操作——输入为空提示"请输入查询内容";无匹配结果提示"⚠️ 未找到匹配的功能";查询成功后显示"已查询 X 个业务功能函数(M 个无匹配)"并接默认操作提示。 + +#### 5.3.2 UFT3 功能查询 + +- 界面与 UF20 功能查询一致(函数名 / 功能号检索、**批量关键词**、精确匹配),结果列为 `函数名`、`英文名`、`功能号`,查询提示同样在页面底部提示栏反馈; +- 仅用于查询核对 UFT3 功能元数据,**双击/右键不加入转码清单**; +- 代码检索中"原代码已支持"行前的功能号即来自 UFT3 元数据。 + +### 5.4 数据映射 + +**说明**:数据映射维护 UF20 → UFT3 的**替换对照表**,转码引擎按映射替换代码中的表名 / 函数名。两类映射均保存在 `cust.json`(见 §6.1);界面修改后点 **保存** 写盘生效,外部编辑 `cust.json` 保存后界面自动刷新(程序自身保存后的 2 秒内暂停文件监控,此间的外部修改需再次保存文件才会被检测到)。 + +#### 5.4.1 表替换 + +维护 UF20 表名 → UFT3 表名的映射。 + +**操作步骤:** + +1. 进入 **数据映射 → 表替换**; +2. 顶部搜索框实时过滤两列内容(不区分大小写); +3. **双击单元格**直接修改; +4. 点 **添加** 弹窗输入 UF20 / UFT3 表名(UF20 表名必填且不可重复); +5. **删除选中** 需二次确认(支持多选); +6. 修改后点 **保存** 生效。 + +#### 5.4.2 函数替换 + +维护 UF20 函数名 → UFT3 函数名的映射,操作方式与表替换完全相同。 + +### 5.5 系统设置(底部"设置") + +**环境配置**: + +| 配置项 | 说明 | +| --- | --- | +| UFT30项目环境路径 | UFT30 项目根目录 | +| UF20项目环境路径 | UF20 项目根目录 | +| UF20账户项目环境路径 | UF20 账户项目根目录 | +| 转码生成路径 | 转码结果输出目录 | + +- 路径保存在 `config\config.ini` 的 `[projectPath]` 节,供元数据更新与转码引擎使用; +- 保存时程序自动同步维护 `uft30pub = \upub\dev_codes`; +- **元数据更新(§5.1)按这些路径扫描源码,首次使用必须先配置**。 + +**系统配置**: + +- 主题模式:日间模式 / 夜间模式,即时切换。 + +**按钮**: + +- **保存设置**:写盘生效; +- **恢复默认**:清空全部路径与主题并恢复默认(二次确认)。 + +## 6. 配置文件说明 + +### 6.1 cust.json + +- **路径**:`bin\uf2touft3\config\cust.json` +- **作用**:一个文件承载三类配置——全量转码清单、表替换映射、函数替换映射。 + +| 节点 | 结构 | 用途 | +| --- | --- | --- | +| `convert.dir` | 数组,元素为 `"函数名.service_design"` | 全量转码清单 | +| `tableCompareUf2_Uf3` | 对象 `{UF20表名: UFT3表名}` | 表替换映射 | +| `functionReplace` | 对象 `{UF20函数名: UFT3函数名}` | 函数替换映射 | + +**手工编辑示例(以实际文件结构为准):** + +```json +{ + "convert": { + "dir": [ + "LS_CustQry.service_design", + "LS_AcctChk.service_design" + ] + }, + "tableCompareUf2_Uf3": { + "UF20表A": "UFT3表A" + }, + "functionReplace": { + "UF20函数1": "UFT3函数1" + } +} +``` + +**编辑注意:** + +- 程序内修改(全量转码 / 表替换 / 函数替换页)会自动写回该文件; +- 手工编辑保存后各页面自动检测并刷新; +- 全量转码清单函数名可省略 `.service_design` 后缀展示,`dir` 为数组结构,程序保存时保留文件其余内容与格式。 + +### 6.2 cust_nonls.json(按需转码清单) + +- **路径**:`bin\uf2touft3\config\cust_nonls.json` +- **作用**:保存按需转码功能清单及每项的递归标志,供界面与转码引擎共用。 + +| 节点 | 结构 | 说明 | +| --- | --- | --- | +| `convert.dir` | 对象 `{功能名: 0 或 -1}` | `0` = 否(单文件转码),`-1` = 是(递归转码) | +| `convert.dir["*"]` | 引擎兜底键 | 非清单功能,界面不展示、保存时自动保留 | +| `convert.ext` | 后缀数组 | 引擎识别的功能文件后缀,程序保存时自动维护 | + +**手工编辑示例(以实际文件结构为准):** + +```json +{ + "convert": { + "dir": { + "*": -1, + "AS_CustQry": 0, + "LF_AcctChk": -1 + }, + "ext": [ + ".service_design", + ".function_design", + ".aservice_design", + ".procedure_design", + ".afunction_design" + ] + } +} +``` + +**编辑注意:** + +- 保持 JSON 格式合法(逗号、引号完整);若格式非法,程序将保留内存中的现有清单并在日志中记录警告; +- 兼容旧版 `dir` 为数组的格式(无递归标志,一律按"否"加载); +- 外部编辑保存后约 0.3 秒内界面自动刷新;程序自身写入该文件时不会触发重复刷新,可放心与手工编辑混用。 + +### 6.3 config.ini 与 change_code.ini + +- **`bin\uf2touft3\config\config.ini`**:`[projectPath]` 节保存设置页的项目环境路径(`uft30` / `uf20` / `ufAcct20` / `createPath` / `uft30pub`),供元数据更新与转码引擎使用; +- **`bin\uf2touft3\config\change_code.ini`**:程序私有配置(与 config.ini 分离,避免程序更新时被覆盖): + - `[log]` 节:日志配置(`file`:日志基础文件名,默认 `change_code.log`,输出到 exe 目录 `logs\` 下,文件名带当天日期前缀); + - `codeSearch` 节:代码检索两种模式的检索路径持久化。 + +## 7. 常见问题(FAQ) + +### 7.1 元数据与索引问题 + +**Q1:全量转码/按需转码页"是否存在"列全部显示"否"?** +A:元数据缓存未就绪。请先确认"设置"页项目环境路径配置正确,再到"元数据更新"页执行一次更新并等待完成。 + +**Q2:首次打开页面"是否存在"显示"否",重开程序后变正常?** +A:已知问题——首次打开时索引尚未加载完成。规避方法:执行元数据更新后再进入转码页面;后续版本将实现索引就绪后自动刷新。 + +### 7.2 业务转码问题 + +**Q3:全量转码"添加"时提示函数无法加入?** +A:全量转码仅支持 **LS_ 开头**的功能。非 LS 功能请切到业务转码的"按需转码"Tab 添加。 + +**Q4:递归模式选"是"后转码范围包含核心文件吗?** +A:不包含。按需转码(无论是否递归)均不处理核心文件、module 模块文件。 + +**Q5:切换"是否递归"后需要保存吗?** +A:不需要,下拉选择后立即写入 `cust_nonls.json` 并生效。 + +### 7.3 代码检索、清单与转码执行问题 + +**Q6:双击/右键 UF20 功能查询中的函数加入了哪个清单?** +A:按功能名自动分流——**LS_ 开头**加入"全量转码",**其它功能**加入"按需转码";可 Ctrl/Shift 多选后批量加入,结果合并为一个提示框(重复项自动跳过)。UFT3 功能查询仅用于核对,不加入清单。 + +**Q7:手工修改 cust_nonls.json / cust.json 后界面没刷新?** +A:界面刷新有约 0.3 秒防抖延迟;若长时间未刷新,检查 JSON 格式是否合法、文件是否保存在 `bin\uf2touft3\config\` 目录下。 + +**Q8:代码检索双击函数提示"请先刷新索引"?** +A:手动更改过检索路径后需点"刷新索引"重新加载;首次显示页面通常会自动构建索引。 + +**Q9:点击"开始转换"后提示任务失败?** +A:多为转码引擎 `bin\uf2touft3\uf2touft3.exe` 缺失、被杀毒软件隔离或被占用导致启动失败(程序会立即提示并恢复界面,不会卡死)。确认引擎文件存在后重试。 + +**Q10:进度条走到后段却提示"转换失败(N 个文件转换失败)"?** +A:个别功能文件转码失败时,程序会如实提示失败而不会误报"转换完成",进度条停留在当前位置。请到 exe 目录 `logs\` 下查看当天的日志,定位失败文件(日志含 `FAIL` 行与失败汇总)后重试。 + +## 8. 日志与问题反馈 + +### 8.1 日志 + +- 程序运行日志输出到 exe 目录 `logs\` 下(文件名带日期前缀,基础名可在 `config\change_code.ini` 的 `[log]` 节配置,默认 `change_code.log`); +- 转码过程中若出现文件转换失败,日志会记录 `FAIL` 行与失败汇总,界面提示"转换失败(N 个文件转换失败)",请结合日志定位问题; +- 配置保存/加载失败、索引刷新失败等异常均会写入警告日志。 + +### 8.2 问题反馈 + +- 反馈问题时请提供:操作步骤、界面提示内容、日志文件,以及 `cust.json` / `cust_nonls.json` 当前内容。 + +--- + +*配套文档:《详细设计文档》(`docs/详细设计文档.md`),含系统架构图与各模块流程图。* diff --git a/docs/详细设计文档.md b/docs/详细设计文档.md new file mode 100644 index 0000000..e1485d0 --- /dev/null +++ b/docs/详细设计文档.md @@ -0,0 +1,792 @@ +# Uft30ChangeCode 详细设计文档 + +| 文档信息 | 内容 | +| --- | --- | +| 项目名称 | Uft30ChangeCode(UF20 → UFT3 转码工具) | +| 文档版本 | V1.6.0 | +| 更新日期 | 2026-09-14 | +| 技术栈 | Qt 6.6.2(MinGW 64-bit)/ C++ / ElaWidgetTools / SQLite | + +--- + +## 1. 引言 + +### 1.1 编写目的 + +本文档面向开发与维护人员,描述 Uft30ChangeCode 转码工具的总体架构、模块划分、核心流程与关键设计决策,作为后续开发、测试与维护的依据。 + +### 1.2 系统定位 + +Uft30ChangeCode 是一款桌面端 **UF20 → UFT3 代码转码工具**,提供: + +- **元数据更新**:后台解析 UF20/UFT3 源码,构建功能元数据索引并落库(SQLite 双缓存库); +- **业务转码**:**全量转码**(LS_ 功能清单式整体转码,清单存 `cust.json`)与**按需转码**(非 LS 功能,单文件/递归两种模式,清单存 `cust_nonls.json`,转码范围不含核心文件、module 模块文件),转码动作由外部引擎 `bin/uf2touft3/uf2touft3.exe` 执行; +- **代码检索**:功能**调用链路查询**(向上调用方、向下被调),双模式(转码业务/非转码业务),支持导出调用文件与链路文本; +- **功能查询**:检索 UF20 / UFT3 功能元数据,支持**多关键词批量查询**(逗号/分号/空格分隔,OR 并集);UF20 查询结果**双击/右键一键分流**——可多选,LS_ 功能加入全量转码、其它功能加入按需转码,加入结果合并为单次提示;UFT3 仅用于查询核对; +- **数据映射**:维护 UF20 → UFT3 的表替换、函数替换对照表(存 `cust.json`,转码引擎按映射替换代码中的表名/函数名); +- **系统设置**:项目环境路径与主题配置(元数据更新与转码引擎依赖这些路径)。 + +### 1.3 术语约定 + +| 术语 | 说明 | +| --- | --- | +| UF20 / UFT3 | 新旧两代系统版本,转码即将 UF20 风格代码转换为 UFT3 风格 | +| LS 功能 | "内存交易"改造范围的功能类别;**全量转码仅支持 LS_ 开头功能**,按需转码仅针对非 LS 功能 | +| 递归标志 | `cust_nonls.json` 中 `convert.dir` 的取值:`0`=否(不递归),`-1`=是(递归) | +| 元数据 | 从源码解析出的功能/函数索引信息,存于 SQLite 缓存库 | +| 调用链路 | 某功能的向上调用方(谁调用了它)与向下被调(它调用了谁)构成的关系树 | + +--- + +## 2. 总体设计 + +### 2.1 技术选型 + +| 类别 | 选型 | 说明 | +| --- | --- | --- | +| GUI 框架 | Qt 6.6.2(MinGW 64-bit) | 跨平台 C++ 桌面框架 | +| UI 组件库 | ElaWidgetTools | Fluent 风格控件(ElaWindow / ElaPivot / ElaTableView / ElaComboBox 等) | +| 本地存储 | Qt SQL(QSqlDatabase) | SQLite 双缓存库 `uf20_cache.db` / `uft3_cache.db`,由 `DataCache` 单例统一管理 | +| 并发 | QtConcurrent + 信号槽 | 元数据后台解析、代码索引后台构建,避免阻塞 UI | +| 进程调用 | QProcess(`PythonRunner` 封装) | 调用外部转码引擎(打包 exe,兼容 Python 脚本模式) | +| 文件监听 | QFileSystemWatcher(+ QTimer 防抖) | 监听外部修改 `cust.json`(数据映射/全量转码)与 `cust_nonls.json`(按需转码) | +| 日志 | LogManager(单例) | 统一 info/warning/error 日志输出到 exe 目录 `logs\` | + +### 2.2 系统分层架构 + +```mermaid +flowchart TB + subgraph SHELL["主窗口(src/mainwindow)"] + M1["MainWindow(ElaWindow)
页面注册 / 任务调度互斥 / 引擎调用"] + end + + subgraph UI["UI 层(src/pages)"] + A2[元数据更新 MetadataUpdatePage] + A3a["业务转码容器 BusinessConvertPage
(ElaPivot:全量/按需)"] + A3[全量转码 BatchConvertPage] + A4[按需转码 NonLsConvertPage] + A5["功能查询 FunctionSearchPage
(UF20 / UFT3 两个子页)"] + A6[代码检索 CodeSearchPage] + A7["数据映射 TableMappingPage
FunctionMappingPage"] + A8[设置 SettingsPage] + A9[关于 AboutDialog] + end + + subgraph CORE["业务核心层"] + B1["业务策略(src/utils)
BusinessStrategy + Factory
UF20 / UFT3 实现"] + B2["配置读取器(src/utils)
ConfigReaderBase + Traits
UF2ConfigReader / UFT3ConfigReader"] + B3["元数据解析器组(src/metadataupdate)
MetaDataProcessor / FileDB
+ uf2* / uft3* 解析器"] + end + + subgraph UTIL["公共支撑层(src/utils)"] + C1[DataCache 缓存读写] + C2[CallChainAnalyzer 调用链分析] + C3[ConfigManager 配置管理] + C4[LogManager 日志] + C5[Constants 常量] + C6[VersionChecker 更新检查] + end + + subgraph EXT["引擎与外部资源"] + D1[PythonRunner
QProcess 封装] + D2[("bin/uf2touft3/uf2touft3.exe
转码引擎")] + D3[("bin/uf2touft3/cache/
uf20_cache.db / uft3_cache.db")] + D4[("bin/uf2touft3/config/
cust.json / cust_nonls.json")] + D5[("bin/uf2touft3/change_code/
默认代码检索根目录")] + D6[UF20/UFT3 源码目录] + end + + M1 --> UI + M1 --> D1 + D1 --> D2 + A3 -.读写.-> D4 + A4 -.监听/读写.-> D4 + A7 -.监听/读写.-> D4 + A6 -.扫描.-> D5 + B3 -.解析.-> D6 + B3 --> C1 + C1 --> D3 + C2 --> C1 + UI --> CORE + UI --> UTIL + CORE --> UTIL +``` + +**分层职责:** + +- **主窗口**:ElaWindow 容器与导航注册;**全局唯一的 `PythonRunner` 实例**,接收全量/按需转码页的 `startConvert` 信号,负责**任务互斥**、引擎进程启停与输出回收; +- **UI 层**:页面构建、用户交互、表格/树渲染,不直接访问数据库/源码,统一经由支撑层与核心层完成; +- **业务核心层**:以**策略模式**隔离 UF20/UFT3 差异(`BusinessStrategy`),以**模板方法模式**统一配置读取流程(`ConfigReaderBase` + Traits);元数据解析器组负责将源码解析为结构化键值数据; +- **公共支撑层**:缓存读写、调用链分析、配置、日志、版本检查等横切能力。 + +### 2.3 目录结构 + +``` +Uft30ChangeCode/ +├── src/ +│ ├── mainwindow/ # MainWindow(ElaWindow 主窗口 + 导航注册 + 转码任务调度) +│ ├── metadataupdate/ # 元数据解析核心(MetaDataProcessor / FileDB +│ │ # + uf2datatype / uf2stdfields / uf2table / uf2interface +│ │ # / uf2errnumber / uf2menuandfunction / uf2module 等) +│ ├── pythonrunner/ # PythonRunner(外部转码引擎进程封装,QProcess) +│ ├── components/ # 通用组件(ElaSearchEdit 搜索框、InterDialog 弹窗) +│ ├── pages/ +│ │ ├── businessconvert/ # 业务转码容器页(ElaPivot:全量/按需 Tab) +│ │ ├── batchconvert/ # 全量转码页 +│ │ ├── nonlsconvert/ # 按需转码页 +│ │ ├── codesearch/ # 代码检索页(调用链路查询) +│ │ ├── functionsearch/ # 功能查询(FunctionSearchPage 基类 + UF20/UFT3 子页) +│ │ ├── datamapping/ # 数据映射(表替换 / 函数替换) +│ │ ├── metadatupdate/ # 元数据更新页 +│ │ ├── settings/ # 设置页 +│ │ └── about/ # 关于(AboutDialog 对话框) +│ └── utils/ # Constants.h / business_strategy / configreaderbase +│ # / uf2configreader / uft3configreader / callchainanalyzer +│ # / datacache / configmanager / logmanager +│ # / version / versionchecker +├── 3rd/ElaWidgetTools/ # 第三方 UI 库 +├── bin/ +│ ├── Uft30ChangeCode.exe # 构建产物 +│ └── uf2touft3/ +│ ├── uf2touft3.exe # 转码引擎 +│ ├── config/ # cust.json / cust_nonls.json / config.ini / change_code.ini +│ ├── cache/ # uf20_cache.db / uft3_cache.db(元数据缓存库) +│ └── change_code/ # 默认代码检索根目录 +└── docs/ # 本文档与使用手册 +``` + +### 2.4 模块清单 + +| 模块 | 主要类 | 设计模式 | 职责 | +| --- | --- | --- | --- | +| 主窗口导航 | `MainWindow` | — | ElaWindow 容器、页面注册、**转码任务调度与互斥**、引擎进程管理 | +| 业务转码容器 | `BusinessConvertPage` | — | ElaPivot 承载全量/按需两个工作台 | +| 全量转码 | `BatchConvertPage` | — | `cust.json` 的 `convert.dir` 清单管理(仅 LS_ 功能)、触发转码 | +| 按需转码 | `NonLsConvertPage` | — | 非 LS 功能清单管理、递归模式选择、外部修改同步 | +| 功能查询 | `FunctionSearchPage`(基类)、`UF20/UFT3FunctionSearchPage` | 模板方法(基类复用) | 元数据检索(多关键词批量查询);UF20 双击/右键一键分流加入转码清单(合并单次提示),UFT3 纯查询 | +| 代码检索 | `CodeSearchPage`、`CallChainAnalyzer` | — | 调用链路查询、索引构建与缓存、导出 | +| 数据映射 | `TableMappingPage`、`FunctionMappingPage` | — | `cust.json` 表替换/函数替换映射维护 | +| 元数据更新 | `MetadataUpdatePage` + `src/metadataupdate/` 解析器组 | 观察者(信号槽) | 后台解析源码、进度上报、原子落库 | +| 设置 | `SettingsPage` | — | 项目环境路径、主题配置 | +| 缓存 | `DataCache` | 单例 | QSqlDatabase 双库读写、功能查询内存缓存 | +| 引擎调用 | `PythonRunner` | — | 外部进程封装(打包 exe / Python 脚本双模式) | +| 日志 | `LogManager` | 单例 | 统一日志 | +| 版本检查 | `VersionChecker` | — | 启动时在线检查新版本(`update_info.json`) | + +--- + +## 3. 全局流程 + +### 3.1 应用启动流程 + +```mermaid +flowchart TD + S([main 入口]) --> A[初始化 QApplication / 高分屏属性] + A --> B[初始化 LogManager 日志单例] + B --> C[初始化 ConfigManager 读取全局配置] + C --> D[构造 MainWindow ElaWindow] + D --> E["注册导航页面
功能转码:业务转码 / 代码检索
功能查询:UF20 / UFT3
数据映射:表替换 / 函数替换
元数据更新;底部:设置 / 帮助 / 关于"] + E --> F[DataCache.initDatabase
打开 uf20_cache.db / uft3_cache.db] + F --> G{缓存库是否有效?} + G -- 是 --> H[正常进入主界面] + G -- 否/为空 --> I[各页面提示: 请先执行元数据更新] + H --> J[VersionChecker 后台检查在线版本] + I --> J + J --> K["singleShot(0) 首帧渲染后:
异步预热 uf20 / uft3 功能缓存"] + K --> L([等待用户操作]) +``` + +> 说明:页面在启动时统一注册到 `MainWindow`(ElaWindow 侧边栏分组导航 + 底部按钮)。`DataCache` 在启动时打开两个缓存库(不存在则自动创建),功能查询使用**内存缓存**(`refreshFunctionCache`),首次查询前需元数据落库。启动流程末尾经 `QTimer::singleShot(0)` 在首帧渲染后提交线程池**异步预热**两库功能缓存(`warmupFunctionCacheAsync`,幂等:已就绪或在途则跳过),用户进入功能查询页时无需再等待主线程重建(详见 §8.6)。 + +### 3.2 页面导航机制 + +- `MainWindow` 基于 `ElaWindow` 实现,侧边栏按分组注册页面:**功能转码**(业务转码、代码检索)、**功能查询**(UF20、UFT3)、**数据映射**(表替换、函数替换)、**元数据更新**;底部为设置、帮助、关于; +- 业务转码页内部通过 ElaPivot 切换全量/按需两个工作台; +- 页面切换由 ElaWindow 内部路由完成,各页面通过 `showEvent` 感知可见性变化(如按需转码页每次显示时刷新表格,代码检索页每次显示时重读 `cust.json` 清单并按需构建索引)。 + +--- + +## 4. 核心数据流 + +### 4.1 元数据更新流程 + +元数据更新是全工具的数据基石:解析 UF20/UFT3 源码目录,构建功能/函数索引并写入 SQLite 缓存库。解析在**工作线程**中执行,通过信号槽向页面汇报进度,UI 不卡顿。支持**增量更新**(按源文件 mtime 比对跳过未变更文件)与**强制全量更新**(二次确认后全量重扫)。 + +```mermaid +sequenceDiagram + participant U as 用户(元数据更新页) + participant P as MetadataUpdatePage + participant R as 元数据解析器组
(src/metadataupdate) + participant T as 工作线程 + participant C as DataCache + participant DB as (SQLite 缓存库) + + U->>P: 勾选更新范围 / 点击"更新UFT30 / 更新UF20" + P->>R: 启动解析任务(增量 或 强制全量) + R->>T: 提交后台解析 + loop 遍历源码文件 + T->>T: 解析元数据(uf2* / uft3* 解析器) + T->>P: 信号: 进度 / 当前处理项 + T->>C: saveDataset(键值行, srcMtime) + C->>DB: 原子写(临时表 + RENAME)+ 更新 _meta + end + T-->>R: 解析完成 + R->>P: 信号: 更新完成(统计结果) + P->>C: refreshFunctionCache() + P->>U: 展示结果 +``` + +**设计要点:** + +1. **后台化**:解析与写库全部在工作线程,UI 线程仅收发信号; +2. **原子落库**:`saveDataset` 以"临时表 + RENAME"方式整体替换表数据,并更新 `_meta` 元信息表(记录源文件 mtime,供增量判断); +3. **增量/全量**:默认增量(mtime 比对),且先做**源码目录整体 mtime 快速比对**(0.5s 容差),源码无变化时整个更新秒级跳过;"强制全量更新"复选框需二次确认; +4. **缓存生效时机**:更新完成后调用 `refreshFunctionCache` 重建功能查询内存缓存,各页面"是否存在"判断随之生效。 + +### 4.2 数据缓存与存在性判断流程 + +`DataCache` 是所有"是否存在 / 元数据查询"的统一入口,底层经 `QSqlDatabase` 访问 SQLite,功能查询走内存索引缓存。 + +```mermaid +flowchart TD + A([页面发起查询
如 checkUF20CnameExists / searchUF20Functions]) --> B{内存功能缓存
是否已构建?} + B -- 否 --> C[读取 *interface 表
refreshFunctionCache 建内存索引] + C --> D{加载成功?} + D -- 是 --> E[在内存索引中匹配] + D -- 否 --> F[返回 未找到
并记录 logWarning] + B -- 是 --> E + E --> G{命中?} + G -- 是 --> H[返回 存在 + 元数据
cname / eName / functionNo] + G -- 否 --> F +``` + +### 4.3 配置读取流程(模板方法 + Traits) + +`ConfigReaderBase` 定义统一的读取骨架(打开文件 → 逐段解析 → 校验 → 输出结构化配置),UF20 与 UFT3 的差异通过子类 + `ConfigReaderTraits` 萃取,避免大量 if-else 分支。 + +```mermaid +flowchart TD + A([调用方指定版本]) --> B{版本?} + B -- UF20 --> C[UF2ConfigReader] + B -- UFT3 --> D[UFT3ConfigReader] + C --> T["Traits<UF2>
关键字段/节名/正则差异"] + D --> T2["Traits<UFT3>
关键字段/节名/正则差异"] + T --> E[ConfigReaderBase::read
模板方法统一骨架] + T2 --> E + E --> F[打开配置文件] + F --> G{打开成功?} + G -- 否 --> X[logWarning + 返回失败] + G -- 是 --> H[逐节解析 parseSection 子类实现] + H --> I{校验通过?} + I -- 否 --> X + I -- 是 --> J[输出结构化配置对象] + X --> K([结束]) + J --> K +``` + +### 4.4 转码执行流程(引擎调用) + +转码动作由外部引擎 `bin/uf2touft3/uf2touft3.exe` 执行。`MainWindow` 持有全局唯一 `PythonRunner`(`UsePackagedExe` 模式,兼容 Python 脚本模式),并负责**全量/按需任务互斥**。 + +```mermaid +sequenceDiagram + participant U as 用户 + participant P as BatchConvertPage /
NonLsConvertPage + participant M as MainWindow + participant R as PythonRunner + participant E as uf2touft3.exe(引擎) + + U->>P: 点击"开始转换" + P->>M: startConvert 信号 + M->>M: beginConvert 任务互斥
(禁用另一 Tab 的开始按钮) + M->>R: 组装参数并启动(UsePackagedExe) + R->>E: QProcess 启动引擎进程 + loop 引擎运行 + E-->>R: stdout / stderr + R->>M: standardOutput / standardError 信号 + M->>M: 前置失败检测(FAIL 行计数)
再解析翻译进度 + M->>P: 进度与状态展示(进度条 / 停止转换按钮) + end + E-->>R: finished(exitCode) + R->>M: finished 信号 + M->>M: 成败路由: exitCode==0 且 m_failCount==0
→ onConvertFinished / 否则 onConvertFailed + M->>M: 解除任务互斥,展示结果 + 日志 + M->>M: 成功时: 代码检索页 refreshIndexSilent()
(双模式静默刷新索引) +``` + +**要点:** + +- **全量转码**:引擎读取 `cust.json` 的 `convert.dir` 清单,对清单内 LS_ 功能整体转码; +- **按需转码**:`NonLsConvertPage::buildArguments()` 组装引擎参数(含 `-m 2` 模式标识),功能清单与递归标志由 `cust_nonls.json` 提供,引擎按映射关系(`cust.json`)完成表名/函数名替换; +- **成败判定**:引擎在个别文件转码失败时退出码仍可能为 0,因此 `MainWindow` 在输出解析链最前端检测 `FAIL` 行(`[N/M] FAIL 函数名`)与汇总行(`批量转码完成: X/Y 通过, Z 失败`)累计 `m_failCount`;收尾按 `正常退出 && 退出码==0 && m_failCount==0` 判定成功,失败时页面调用 `onConvertFailed`——进度条**保持当前进度**(不置 100%)、状态文字"转换失败(N 个文件转换失败)"(详见 §8.7); +- **转码后联动**:任务成功后自动静默刷新代码检索索引,保证调用链路反映最新代码。 + +--- + +## 5. 业务模块详细设计 + +### 5.1 业务转码容器(BusinessConvertPage) + +**职责**:单页承载全量转码与按需转码两个工作台,顶部 ElaPivot Tab 切换。 + +- 两个工作台(`BatchConvertPage` / `NonLsConvertPage`)作为子页内嵌; +- **任务互斥**在 `MainWindow` 层统一控制:任一工作台转换期间,另一工作台的"开始转换"按钮禁用并提示等待,避免并发冲突。 + +### 5.2 全量转码模块(BatchConvertPage) + +**职责**:维护 LS_ 功能转码清单并对功能执行整体转码,表格展示"UF20函数名 / 是否存在 / 操作"。 + +```mermaid +flowchart TD + A([进入全量转码页]) --> B["加载清单
cust.json → convert.dir 数组"] + B --> C[updateTable 渲染 ElaTableView] + C --> D{逐行判断} + D --> E["checkUF20CnameExists(函数名)
查询 DataCache"] + E --> F["存在 → 显示 是
不存在 → 显示 否"] + F --> G{用户点击开始转换} + G --> H["startConvert 信号 → MainWindow
任务互斥 + PythonRunner 启动引擎"] + H --> I{转码结果} + I -- 成功 --> K[提示成功 + 日志] + I -- 失败 --> L[提示失败原因 + logWarning] +``` + +**要点:** + +- 清单**仅允许 LS_ 开头**的功能(`validateLsFunctionNames` 校验),非 LS 输入被拒绝并提示切换到按需转码; +- 清单持久化于 `cust.json` 的 `convert.dir` 数组(元素为 `"函数名.service_design"`);保存时用**正则定位数组做局部替换**,保留文件其余内容与格式; +- `QFileSystemWatcher + QTimer`(300ms 防抖)监听 `cust.json` 外部修改,与手工编辑双向同步; +- "是否存在"列依赖元数据缓存;**首次运行若未执行元数据更新,可能整列显示"否"**(详见 §8.2)。 + +### 5.3 按需转码模块(NonLsConvertPage) + +**职责**:管理"非 LS 功能"的按需转码清单,支持**单文件转码**与**递归转码**两种模式(下拉选择),转码范围不包含核心文件、module 模块文件。清单持久化于 `bin/uf2touft3/config/cust_nonls.json`,并监听外部修改。 + +#### 5.3.1 数据结构 + +| 成员 | 类型 | 说明 | +| --- | --- | --- | +| `m_funcList` | `QStringList` | 功能名有序清单(与表格行序一致) | +| `m_recursionMap` | `QMap` | 功能名 → 递归标志(`0`=否,`-1`=是),与 json 的 `convert.dir` 同值 | +| `m_tableModel` | `QStandardItemModel` | 表格模型,列:`UF20函数名 / 是否存在 / 是否递归 / 操作` | +| `m_configWatcher` | `QFileSystemWatcher` | 监听 `cust_nonls.json` 文件与其所在目录 | +| `m_reloadTimer` | `QTimer` | 防抖定时器,外部修改后延迟重载 | +| `m_lastFileMtime` | `QDateTime` | 上次已处理的文件修改时间,用于过滤自身写入触发的变更 | + +#### 5.3.2 添加功能流程(来自 UF20 功能查询页双击) + +```mermaid +sequenceDiagram + participant U as 用户 + participant S as UF20FunctionSearchPage + participant N as NonLsConvertPage + participant J as cust_nonls.json + + U->>S: 双击非 LS 函数 + S->>S: 校验非 LS / 去重 + S->>N: addFunction(函数名) + N->>N: m_funcList.append(name) + N->>N: m_recursionMap.insert(name, 0) // 默认"否" + N->>N: updateTable() // 立即渲染新行 + N->>J: saveToCustNonLsJson(m_funcList) + Note over N,J: 写盘后刷新 m_lastFileMtime,
避免 watcher 误判为外部修改 + N-->>U: 弹窗"已将功能 [x] 添加到按需转码" +``` + +#### 5.3.3 表格刷新流程(updateTable) + +```mermaid +flowchart TD + A([updateTable]) --> B["removeRows 清空模型
(不使用 blockSignals)"] + B --> C{遍历 m_funcList} + C --> D[构建行: 函数名 / 是否存在
存在性查询 DataCache] + D --> E["是否递归列: setIndexWidget
放置 ElaComboBox(否/是)"] + E --> F["操作列: setIndexWidget
放置编辑按钮"] + F --> G{还有下一行?} + G -- 是 --> D + G -- 否 --> H["viewport()->update()
resizeRowsToContents()
scrollToTop() / clearSelection()"] + H --> I([刷新完成]) +``` + +**关键设计决策——禁用 `blockSignals`:** + +早期版本在重建模型时使用 `m_tableModel->blockSignals(true)` 防止勾选误触发写盘。但 `QTableView` 依赖 `rowsInserted/rowsRemoved` 信号更新内部布局缓存,屏蔽后**新增行不可见**(数据层行数正确、视图层不渲染)。现方案: + +- 重建期间不屏蔽信号; +- 误触发防护改由数据侧保证:`m_recursionMap` 先行预填充,下拉切换回调中"**值相等则短路**",重建时 `setCurrentIndex` 不会产生多余写盘。 + +#### 5.3.4 递归模式切换流程 + +```mermaid +flowchart TD + A([用户切换下拉 否/是]) --> B["currentIndexChanged(index)"] + B --> C["recursion = index==1 ? -1 : 0"] + C --> D{m_recursionMap 中
旧值 == 新值?} + D -- 是 --> E([短路返回, 不写盘]) + D -- 否 --> F[m_recursionMap 更新] + F --> G[saveToCustNonLsJson 持久化] + G --> H[刷新 m_lastFileMtime] + H --> I([完成]) +``` + +#### 5.3.5 外部修改同步流程(文件监听 + 防抖) + +```mermaid +flowchart TD + A["QFileSystemWatcher
fileChanged / directoryChanged"] --> B[启动/重启 m_reloadTimer 防抖] + B --> C{"onReloadTimeout:
当前 mtime != m_lastFileMtime ?"} + C -- 否 --> D([忽略: 自身写入或无效变更]) + C -- 是 --> E[loadFromCustNonLsJson 重新解析] + E --> F{解析成功?} + F -- 是 --> G[m_funcList / m_recursionMap 更新
updateTable 重绘] + F -- 否 --> H[logWarning + 保留内存现状] + G --> I([界面与磁盘一致]) + H --> I +``` + +**要点:** + +- `fileChanged` 在部分编辑器保存时会**移除监听**,回调中检测到即对路径重新 `addPath`,保证持续监听; +- 目录监听用于捕获"文件被删除重建"的场景; +- mtime 比较过滤掉程序自身写盘引发的回环刷新; +- **兼容旧版格式**:旧版 `convert.dir` 为数组(无递归标志),加载时自动兼容、一律按"否"处理;新版为对象 `{功能名: 0|-1}`,引擎兜底键 `"*"` 由程序保存时自动保留。 + +#### 5.3.6 按需转码执行模式 + +| 模式 | 递归标志 | 行为 | +| --- | --- | --- | +| 非递归(单文件) | `0` | 仅对目标文件本身执行转码,不处理该文件内调用的函数 | +| 递归 | `-1` | 转码目标文件的同时,自动对文件中所调用的关联函数一并完成转码 | + +两种模式均**不包含核心文件、module 模块文件**。执行时 `buildArguments()` 组装引擎参数(含 `-m 2`),由 `MainWindow` 统一经 `PythonRunner` 启动引擎。 + +### 5.4 功能查询模块(FunctionSearchPage 基类 + UF20/UFT3 子页) + +**职责**:检索 UF20 / UFT3 功能元数据(依赖 `DataCache`),列:`函数名 / 英文名 / 功能号`;支持函数名/功能号字段切换、精确匹配开关与**多关键词批量查询**。UF20 页结果表支持多选,双击/右键一键加入转码工作台;UFT3 页为纯查询(不接入转码链路)。 + +#### 5.4.1 多关键词批量查询 + +- **拆词**:查询框输入按**中英文逗号、分号与空白**统一拆分为关键词列表(`splitKeywords`,全角字符以 `QStringLiteral` 字符串字面量处理,规避多字节字符常量问题); +- **匹配语义**:多关键词之间为 **OR 并集**——任一关键词在所选字段(函数名/功能号/任一)命中即收录结果; +- **去重**:结果按 `cname + '|' + functionNo` 组合键去重(同一功能可能被多个关键词或多个字段重复命中); +- **零命中统计**:`DataCache` 多词检索接口带 `keywordsWithoutMatch` 出参,返回零命中关键词个数; +- **接口形态**:单词版 `searchUF20Functions(keyword, ...)` 内部委托多词版(`QStringList{keyword}`),两库多词重载共用匿名命名空间 `filterFunctionInfos()` 的单次遍历实现。 + +**查询反馈(tip 栏)**:页面底部提示栏随查询状态变化——空关键字提示"请输入查询内容"、无结果提示"⚠️ 未找到匹配的功能"、成功后显示"已查询 X 个业务功能函数(M 个无匹配)"并拼接默认操作提示(UF20 含双击/右键操作说明与批量查询示例),全程不弹模态对话框;提示文字颜色经 `ElaTheme` 主题取色并连接 `themeModeChanged` 信号联动刷新,日间/夜间模式均可读。 + +#### 5.4.2 UF20 功能查询一键加入转码工作台 + +UF20 页结果表为**扩展多选**(ExtendedSelection)+ 自定义右键菜单;**双击与右键共用**收集逻辑 `addSelectedRowsToConvert()`:收集选区(或右键行)按 `LS_` 前缀分流为两个列表,**一次 emit** 合并信号: + +```mermaid +sequenceDiagram + participant U as 用户 + participant S as UF20FunctionSearchPage + participant M as MainWindow + participant B as BatchConvertPage(全量) + participant N as NonLsConvertPage(按需) + + U->>S: 双击 / 右键"一键添加功能"(可多选) + S->>S: 收集选区,按 LS_ 前缀分流 + S->>M: addFunctionsToConvert(lsNames, nonLsNames) + M->>B: addFunctionsBatch(lsNames, notify=false) 静默 + M->>N: addFunctionsBatch(nonLsNames, notify=false) 静默 + Note over B,N: 各自去重(已存在跳过)、写 cust.json / cust_nonls.json;
返回 {新增数, 跳过数},保存失败返回 {-1,-1} 并弹警告 + M->>U: 合并为单个提示框(RichText):
"已将 N 个功能加入全量转码,已将 M 个功能加入按需转码" +``` + +**要点:** + +- **单次提示**:两工作台 `addFunctionsBatch` 支持静默模式(`notify=false` 不弹任何提示框),由 `MainWindow` 统一汇总——加入成功后**只弹一个**消息框,"全量转码"/"按需转码"字样经 `Qt::RichText` **加粗**;重复项自动跳过并注明"另有 N 个已存在";全部已存在时提示"均已在转码列表中"; +- **右键交互**:右键行不在选区内时设为唯一选中(Windows 惯例),在选区内则保持多选;空白区域右键不弹菜单; +- 批量添加**逐条免弹窗**,替代早期逐条模态确认。 + +> **UFT3 功能查询**:界面与 UF20 版一致(含批量关键词查询),仅用于核对 UFT3 元数据,**双击/右键不加入转码清单**;代码检索中"原代码已支持"行的功能号前缀即来自 UFT3 元数据。 + +### 5.5 代码检索模块(CodeSearchPage + CallChainAnalyzer) + +**职责**:功能**调用链路查询**(非内容检索)——展示某功能的向上调用方(谁调用了它)与向下被调(它调用了谁),用于转码前评估影响范围、转码后核对结果。 + +#### 5.5.1 双模式设计 + +页面顶部 ElaPivot 切换两种模式,各模式**独立的分析器实例、检索路径、调用链树**,互不干扰: + +| 模式 | 检索入口 | 导出 | +| --- | --- | --- | +| 转码业务 | 左侧"业务转码函数列表"(每次显示时重读 `cust.json` 的 `convert.dir`),双击查询 | 导出调用文件(复制需转码文件)+ `_call_chain.txt` | +| 非转码业务 | 顶部输入行:**函数名**(支持 `功能号-函数名` 带前缀格式)或**功能号**(经 UFT3 元数据解析为函数英文名)查询 | 仅导出 `_call_chain.txt` | + +检索路径按模式持久化于 `change_code.ini` 的 `[codeSearch]` 节(`convertPath` / `searchPath`)。 + +#### 5.5.2 索引构建与缓存加速 + +```mermaid +flowchart TD + A([显示页面 / 切换模式]) --> B{索引已就绪?} + B -- 是 --> Z([直接可用]) + B -- "否,且路径未被手动更改" --> C{"cacheQuickValid
快速粗判缓存(stat 根目录子项,毫秒级)"} + C -- 命中 --> D["后台 loadFromCache
(跳过指纹校验,秒级加载)"] + C -- 未命中 --> E[后台 buildIndex 全量构建] + D -- 失败 --> E + E --> F[saveToCache 写缓存] + D --> Z + F --> Z + B -- "否,且路径被手动更改" --> G[提示用户点击"刷新索引"手动触发] +``` + +- 构建与缓存加载均在 `QtConcurrent` 后台线程执行,界面显示模态进度框; +- **手动更改路径**后需点"刷新索引"重新加载(弹窗提示); +- 每次转码任务完成后 `MainWindow` 调用 `refreshIndexSilent()`:双模式**静默**(无弹窗)依次后台刷新并写缓存。 + +#### 5.5.3 调用链查询与树渲染 + +```mermaid +flowchart TD + A([runQuery 函数名]) --> B["buildCallTree(向下被调链)
buildCallerForest(向上调用方森林,每条链以向下链收尾)"] + B --> C[populateTree 渲染调用链树] + C --> D["树列:函数名 / 目录 / 模块
默认展开 2 级,列宽按内容自适应"] + D --> E{节点状态} + E -- ok --> F[普通行] + E -- notfound --> G["[未找到] 红色字"] + E -- cycle --> H["[循环调用] 橙色字"] + E -- depthlimit --> I["[层级截断]"] + E -- supported --> J["[原代码已支持] 整行绿色高亮
行前带 UFT3 功能号前缀(功能号来自 UFT3 元数据)"] + D --> K["当前查询节点整行蓝色高亮
(自定义 Delegate 强制绘制,绕过 Ela 主题覆盖)"] +``` + +- 状态 `supported` 表示函数未在检索目录源码中找到,即无需转码、原代码已支持; +- 高亮经 `RowHighlightRole` / `RowHighlightBlueRole` + 自定义 `CallTreeDelegate` 强制绘制,避免 Ela 主题覆盖背景色。 + +#### 5.5.4 导出设计 + +- **导出调用文件**(转码业务):收集链路上所有非 `supported` 节点的源文件,按相对检索根的目录结构复制到 `<检索路径>\function_output\<查询函数名>\`,并生成链路文本 `_call_chain.txt`(缩进树形,含状态标记); +- **导出调用链路**(非转码业务):仅生成 `_call_chain.txt`,不复制源文件; +- 目录名经 `sanitizeDirName` 清洗 Windows 非法字符。 + +### 5.6 数据映射模块(TableMappingPage / FunctionMappingPage) + +**职责**:维护 UF20 → UFT3 的**表替换**(`tableCompareUf2_Uf3`)与**函数替换**(`functionReplace`)映射,两页代码同构,均读写 `cust.json` 对应节点;转码引擎按映射替换代码中的表名/函数名。 + +```mermaid +flowchart TD + A([进入表替换 / 函数替换页]) --> B["readCustJson 读取映射节点"] + B --> C["渲染表格(QStandardItemModel)
QSortFilterProxyModel 两列不区分大小写过滤"] + C --> D{用户操作} + D -- 双击 / F2 编辑 --> E[更新模型] + D -- 添加 --> F["弹窗:UF20 名必填 + 查重"] + D -- 删除选中 --> G[二次确认(支持多选)] + E --> H[点击保存] + F --> H + G --> H + H --> I["读取原始文本
extractTopLevelKeyOrder 提取顶层 key 顺序"] + I --> J["writeCustJson 按序写回:
本映射节点有序输出,其余节点原样保留"] + J --> K["保存后 2s 内忽略 fileChanged
(QTimer)并重新 addPath"] + L[外部编辑 cust.json] -. fileChanged .-> M[loadData 立即刷新] +``` + +**要点:** + +- 保存采用**保留顶层 key 顺序的整体重写**:先从原始文本用正则提取 key 出现顺序,再按顺序写出,避免 JSON 重排破坏手工维护的文件结构; +- 自身保存后 2 秒内的 `fileChanged` 事件被忽略(防回环),此后恢复监听; +- 添加弹窗校验:UF20 名非空且不与现有行重复。 + +### 5.7 元数据更新页面(MetadataUpdatePage) + +**职责**:元数据更新的人机入口——勾选更新范围、触发更新、展示进度与统计结果(见 §4.1 时序图)。 + +- 页面分 **UFT30更新范围** 与 **UF20更新范围** 两个勾选卡片(UFT30:数据类型/标准字段/表结构/接口数据;UF20:数据类型/标准字段/标准错误号/接口数据/常量/菜单功能/模块/表结构),提供全选/全不选快捷操作; +- **强制全量更新**复选框(勾选需二次确认):默认增量更新(mtime 比对),勾选后全量重扫重建; +- 源码扫描范围来自设置页配置的项目环境路径(见 §5.8)。 + +### 5.8 设置页面(SettingsPage) + +**职责**:环境路径与界面主题配置。 + +| 配置项 | 说明 | +| --- | --- | +| UFT30 / UF20 / UF20账户项目环境路径 | 元数据更新与转码引擎的源码扫描根目录 | +| 转码生成路径 | 转码结果输出目录 | + +- 路径保存于 `config.ini` 的 `[projectPath]` 节;保存时程序自动同步维护 `uft30pub = \upub\dev_codes`; +- 主题模式(日间/夜间)即时切换;"恢复默认"需二次确认。 + +--- + +## 6. 公共支撑模块 + +### 6.1 DataCache(单例) + +- 元数据缓存的门面:双库(`uf20_cache.db` / `uft3_cache.db`,位于 `bin/uf2touft3/cache/`),由 QSqlDatabase 直接管理,无独立 SQLite 封装类;表结构统一为 `(name TEXT PRIMARY KEY, data TEXT)`(`data` 存 JSON),与 Python 侧 `src/store/schema.py` 对齐; +- **原子写**:`saveDataset()` 先写临时表再 `RENAME` 替换正式表,随后更新 `_meta` 表记录源文件 mtime,供增量更新判断; +- **写闸门**:`saveDataset()` 全程持有 `m_writeGate` 写闸门;`clearCache()` 以 `tryLock` 探测在途落库任务——在途时仅重置内存状态并记录警告、跳过连接重置,避免关闭正在写入的连接(闸门在 `database()` 取连接之后获取,锁序安全无死锁,详见 §8.4); +- **内存功能索引**:`refreshFunctionCache()` 将两库功能表加载至内存(`FunctionInfo{cname, eName, functionNo}`),支撑功能查询页即时检索;启动时 `MainWindow` 经 `warmupFunctionCacheAsync(db)` **异步预热**(后台线程构建内存索引,幂等:已就绪/在途跳过,详见 §8.6); +- **检索 API**:`searchUF20Functions()` / `searchUFT3Functions()` 支持按中文名/英文名/功能号字段过滤,均有**单词与多词两个重载**——多词版(`QStringList` + `keywordsWithoutMatch` 出参)实现 OR 并集、按 `cname|functionNo` 去重与零命中统计,单词版内部委托多词版;两库共用匿名命名空间 `filterFunctionInfos()` 过滤实现——代码检索"按功能号检索"即经 `searchUFT3Functions(raw, "function_no", true)` 将功能号解析为函数英文名; +- **数据集 API**:`datasetExists()` / `getAllRows()` / `getRowData()` 供功能查询页读取指定功能完整数据。 + +### 6.2 LogManager(单例) + +- 两步初始化:`initLogConfig()` 读取 `change_code.ini` 的 `[log]` 节(级别/开关),`initLogFile()` 按"日期前缀"命名在 `exe/logs/` 目录创建日志文件; +- 提供 `log / logInfo / logWarning / logError` 四级接口,MainWindow、ConfigManager、ConfigReaderBase 及各页面统一经单例写入。 + +### 6.3 ConfigManager(单例) + +- 管理 `config.ini`(`[projectPath]` 节),启动时全量加载至内存缓存; +- `setValue()` 更新缓存并立即回写文件;读取侧复用 `ConfigReaderBase`(QSettings 兼容读取,加载过程经 LogManager 记录)。 + +### 6.4 Constants.h(全局常量) + +| 常量 | 值 | 用途 | +| --- | --- | --- | +| ToolDir / ChangeCodeDir / FunctionOutput / CacheDir | `uf2touft3` / `change_code` / `function_output` / `cache` | 引擎目录与输出子目录 | +| Exe | `uf2touft3.exe` | 转码引擎可执行文件名 | +| CustJson / CustNonLsJson | `config/cust.json` / `config/cust_nonls.json` | 转码配置文件 | +| Uf20DbFile / Uft3DbFile | `uf20_cache.db` / `uft3_cache.db` | 双缓存库文件名 | +| ChangeCodeIni | `change_code.ini` | 路径持久化与日志配置文件 | +| CodeSearch::ConvertPathKey / SearchPathKey | `codeSearch/convertPath` / `codeSearch/searchPath` | 代码检索路径配置键 | +| updateInfo() | `/download/uft3changecode/update_info.json` | 版本检查地址 | + +另含界面表格列索引、递归扫描标志等常量,集中管理避免魔法数字。 + +### 6.5 CallChainAnalyzer(调用链分析) + +代码检索页持有两个实例(转码业务 / 非转码业务),各自独立索引与缓存。 + +| 环节 | 方法 | 说明 | +| --- | --- | --- | +| 全量构建 | `buildIndex` | 扫描源码目录解析调用关系,完成后 `saveToCache` 持久化 | +| 快速粗判 | `cacheQuickValid` | 比对源目录特征与缓存指纹,秒级判断缓存是否可复用 | +| 缓存加载 | `loadFromCache(skipFingerprint)` | 命中时秒级恢复索引;`skipFingerprint=true` 跳过指纹校验强制重载 | +| 最优匹配 | `lookupBest` | 将输入函数名解析为索引中的目标函数 | +| 向下被调 | `buildCallTree` | 以查询函数为根展开"它调用了谁" | +| 向上调用方 | `buildCallerForest` | 反向展开"谁调用了它" | + +- 节点状态:`ok` / `notfound`(索引中未找到)/ `cycle`(循环调用,终止展开)/ `depthlimit`(超出深度上限)/ `supported`(转码业务中该函数已支持转码,绿色标识); +- 查询流程:双击列表行 → `runQuery` → buildCallTree + buildCallerForest → 填充树控件 → `expandToDepth(2)` 默认展开两层;蓝色当前行、绿色 supported 行经自定义 Role 由 CallTreeDelegate 强制绘制。 + +### 6.6 PythonRunner(引擎进程封装) + +- `RunMode` 枚举:`UsePackagedExe`(打包 exe)/ `UsePythonScript`(Python 脚本)/ `AutoDetect`;本程序固定采用 UsePackagedExe,指向 `bin/uf2touft3/uf2touft3.exe`; +- 封装 QProcess:`start()` 启动、`stop()` 终止、`runSync()` 同步等待;`start()` 为**异步启动**(不再 `waitForStarted` 阻塞 UI 线程),重复发起检查覆盖 `Starting/Running` 状态; +- 信号:`finished(exitCode)` / `standardOutput(QString)` / `standardError(QString)` / `errorOccurred(QProcess::ProcessError)`;MainWindow 将输出汇聚至页面日志区,`finished` 用于解除任务互斥,`errorOccurred` 的 `FailedToStart` 用于启动失败收尾(详见 §8.5)。 + +### 6.7 VersionChecker + +- 启动时经 QNetworkAccessManager 拉取 `update_info.json`,解析多版本发布列表,取 `Version::compare` 比较后的最大版本为最新版; +- 与当前版本比较后发射 `newVersionAvailable(latestVersion, releaseNotes)` / `checkError(msg)` / `noUpdateNeeded()`,MainWindow 弹出更新提示,"关于"页亦可手动检查。 + +--- + +## 7. 数据设计 + +### 7.1 SQLite 缓存库 + +- 位置:`bin/uf2touft3/cache/`,双库 `uf20_cache.db` / `uft3_cache.db`; +- 每个元数据文件一张表:`(name TEXT PRIMARY KEY, data TEXT)`,`data` 为该文件解析结果的 JSON; +- `_meta` 表记录每张表对应的源文件 mtime,元数据更新时据此跳过未变化文件(增量更新); +- 原子写:新建临时表插入完成后 `RENAME` 覆盖正式表,避免中途失败留下半写状态。 + +### 7.2 cust.json(转码配置) + +| 配置 | 内容 | 消费方 | +| --- | --- | --- | +| `tableCompareUf2_Uf3` | UF20→UFT3 表名映射 | 全量/按需转码的表替换 | +| `functionReplace` | 函数替换规则 | 转码的函数替换 | +| 其余键 | 引擎自身配置 | 原样透传 | + +- 数据映射两页各自只读写所属节,其余内容原样保留; +- 程序保存时经 `extractTopLevelKeyOrder` 提取顶层 key 顺序、`writeCustJson` 按原顺序写回,不破坏 JSON 键序。 + +### 7.3 cust_nonls.json(非转码业务配置) + +```json +{ + "convert": { + "dir": { "功能名": 0, "另一功能": -1, "*": 0 }, + "ext": ["c", "h"] + } +} +``` + +- `dir.<功能名>`:`0` 纳入转码、`-1` 不纳入;`"*"` 为兜底键; +- `ext`:纳入转码的扩展名白名单; +- 兼容旧版数组格式(仅扩展名列表),读取时自动转换。 + +### 7.4 ini 配置 + +| 文件 | 节/键 | 说明 | +| --- | --- | --- | +| config.ini | `[projectPath]` | UFT30/UF20/项目环境路径、转码生成路径;保存时同步维护 `uft30pub = \upub\dev_codes` | +| change_code.ini | `[log]` | 日志级别/开关,LogManager 启动时读取 | + +--- + +## 8. 关键设计决策与问题记录 + +### 8.1 表格刷新不使用 blockSignals + +按需转码页 `updateTable()` 全量刷新模型时**有意不用** blockSignals:QTableView 依赖 `rowsRemoved` / `rowsInserted` 信号更新内部布局缓存,屏蔽信号会导致新增行不可见。替代方案:先 `removeRows` 清空再逐行重建;`m_recursionMap` 已预填充,值相等时不写盘,避免刷新回环。 + +### 8.2 启动时引擎存在性校验 + +MainWindow 启动即校验 `bin/uf2touft3/uf2touft3.exe` 是否存在:缺失时记录错误日志并界面提示,后续转码请求直接拒绝,避免 QProcess 启动失败的报错含糊难排查。 + +### 8.3 外部配置修改的监听与防抖 + +两套机制并行,按页面职责选择: + +| 页面 | 监听对象 | 机制 | +| --- | --- | --- | +| 全量转码 / 按需转码 | cust.json / cust_nonls.json | 同时监听**文件与所在目录**(Windows 编辑器原子替换"写临时文件→重命名"常使 fileChanged 失效,directoryChanged 更可靠);变更后经 300ms 防抖定时器,比对 mtime 有变化才重载 | +| 数据映射(表/函数替换) | cust.json | `fileChanged` 后立即重载;程序自身保存后 2 秒内(`m_isSaving` + `QTimer::singleShot(2000)`)忽略变更,防止保存回环 | + +### 8.4 落库与清缓存的并发保护(写闸门) + +`saveDataset()`(后台线程落库)与 `clearCache()`(重置缓存连接)并发时,直接关闭连接会中断在途写入。设计:`saveDataset` 取得数据库连接后全程持有 `m_writeGate` 写闸门;`clearCache` 等待预热任务结束后以 `tryLock` 探测闸门——加锁失败说明落库在途,仅重置内存状态并记录警告、跳过连接重置;加锁成功则持闸门完成连接清理。锁序安全:`saveDataset` 在取闸门前已完成 `database()` 调用(`m_connMutex` 短暂持锁即释放),与 `clearCache` 的"闸门 → 连接锁"顺序不构成死锁环。 + +### 8.5 转码引擎异步启动与幂等收尾 + +`PythonRunner::start()` 原在主线程 `waitForStarted(3000)` 等待启动,杀毒软件扫描引擎 exe 时界面可卡顿达 3 秒。现改为异步启动:`start()` 触发 `QProcess::start` 后立即返回,启动结果经 `started` / `errorOccurred` 信号异步上报。MainWindow 连接 `errorOccurred`,对 `FailedToStart` 立即执行失败收尾(恢复两 Tab 界面并提示);配合 `m_taskActive` 在途守卫,正常结束(`finished`)与启动失败(Qt 对 FailedToStart 亦会补发 `finished(-1, CrashExit)`)两条路径的收尾**幂等**——先到者执行、后到者跳过,避免重复弹窗。`runSync()` 同步语义不受影响(启动失败视为"已结束",`waitForFinished` 立即返回)。 + +### 8.6 功能缓存启动预热 + +功能查询内存缓存(2 万~5 万条)原先在**首次进入页面时于主线程同步重建**(uf20 约 4.3s、uft3 约 2.7s),且 uft3 无触发点、每次进页都重建。现改为**启动即预热**:`MainWindow` 构造尾部经 `QTimer::singleShot(0, ...)` 在首帧渲染完成后调用 `DataCache::warmupFunctionCacheAsync("uf20"/"uft3")`,向 QtConcurrent 线程池提交后台重建;接口**幂等**(缓存已就绪或预热在途时直接返回),进页面时的同步路径仅在预热未完成且等待超限(≤5s)时才兜底重建,正常情况下进页面"接管"预热结果即可,无感知加载。元数据更新落库后 `refreshFunctionCache` 触发重建,预热不干扰该链路。 + +### 8.7 转码失败的准确判定(FAIL 行检测) + +引擎在**个别文件转码失败时退出码仍为 0**(实测:`[N/M] FAIL 函数名` 行后流水线正常收尾),仅凭退出码会把失败误报为"转换完成"。设计:`MainWindow::onPythonRunnerOutput` 在解析链**最前端**做失败检测——正则累计 `FAIL` 行数(含 `[n/m]` 计数格式),并匹配汇总行 `批量转码完成: X/Y 通过, Z 失败` 取最大值,累计至 `m_failCount`;`[n/m]` 翻译进度解析仅在无 FAIL 行时执行(FAIL 行含 `[n/m]` 会被误判为进度百分比)。收尾(`onPythonRunnerFinished`)按 `正常退出 && 退出码==0 && m_failCount==0` 路由成败:失败时调用两页 `onConvertFailed`——进度条**保持当前进度**(不置 100%)、状态显示"转换失败(N 个文件转换失败)"、日志记录 ERROR 并弹窗提示查看 `logs\` 日志。 + +--- + +## 9. 构建与部署 + +### 9.1 构建 + +- Qt 6.6.2(MinGW 64-bit)+ ElaWidgetTools + CMake; +- 第三方库经 `3rdparty/` 引入,构建产出 `bin/Uft30ChangeCode.exe`。 + +### 9.2 部署清单(bin/) + +| 路径 | 说明 | +| --- | --- | +| `Uft30ChangeCode.exe` | 主程序 | +| `uf2touft3/uf2touft3.exe` | 转码引擎(外部进程) | +| `uf2touft3/config/cust.json` | 表/函数映射配置(数据映射页读写) | +| `uf2touft3/config/cust_nonls.json` | 非转码业务配置(业务转码页读写) | +| `uf2touft3/cache/uf20_cache.db` / `uft3_cache.db` | 元数据缓存库(首次元数据更新生成) | +| `config.ini` / `change_code.ini` | 程序路径配置 / 日志配置 | +| `logs/` | 日志输出目录(运行生成) | +| `change_code/`、`uf2touft3/function_output/` | 转码输出 / 调用链导出目录(运行生成) | + +--- + +## 10. 附录:页面与源码对照 + +| 页面/功能 | 源码位置 | +| --- | --- | +| 主窗口与导航、引擎调度 | `src/mainwindow/` | +| 业务转码 | `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/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 diff --git a/help/Writerside/topics/UF20功能查询.md b/help/Writerside/topics/UF20功能查询.md index f3c2f72..f182d30 100644 --- a/help/Writerside/topics/UF20功能查询.md +++ b/help/Writerside/topics/UF20功能查询.md @@ -1,6 +1,6 @@ # UF20功能查询 -查询UFT3系统中的函数信息。 +查询UF20系统中的函数信息。 ## 查询条件 从下拉框选择查询(功能名称、功能编号),输入框输入查询关键词,点击"Aa"切换精确/模糊匹配模式,点击放大镜图标或按回车执行查询 @@ -12,6 +12,13 @@ 模糊查询uf20 精准查询uf20 +## 结果反馈 +查询提示在页面底部 tip 栏显示,不会弹出对话框打断操作: + +- 输入为空 → 提示"请输入查询内容" +- 无匹配结果 → 提示"⚠️ 未找到匹配的功能" +- 查询成功 → 表格展示结果,tip 栏恢复默认操作提示 + ## 双击添加 -双击选中行添加转码业务,只支持LS功能添加 +双击选中行加入转码工作台:LS_ 开头的功能进入"全量转码",其它功能进入"按需转码" 双击添加 diff --git a/help/Writerside/topics/UFT3功能查询.md b/help/Writerside/topics/UFT3功能查询.md index 317025c..71b0526 100644 --- a/help/Writerside/topics/UFT3功能查询.md +++ b/help/Writerside/topics/UFT3功能查询.md @@ -10,3 +10,12 @@ - 功能编号 模糊查询UFT3 精准查询uft3 + +## 结果反馈 +查询提示在页面底部 tip 栏显示,不会弹出对话框打断操作: + +- 输入为空 → 提示"请输入查询内容" +- 无匹配结果 → 提示"⚠️ 未找到匹配的功能" +- 查询成功 → 表格展示结果,tip 栏恢复默认提示 + +> 本页仅提供查询参考,结果行不支持双击加入转码工作台。 diff --git a/main.cpp b/main.cpp index a5c374f..bb7d50f 100644 --- a/main.cpp +++ b/main.cpp @@ -1,7 +1,11 @@ #include "src/mainwindow/mainwindow.h" #include "ElaApplication.h" +#include "ElaTheme.h" #include "src/utils/configmanager.h" +#include "src/utils/logmanager.h" +#include "src/utils/version.h" #include +#include #include #include #include @@ -11,6 +15,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_WIN +#include +#include +#endif #include // 全局文字可复制过滤器: @@ -87,19 +102,126 @@ private: int main(int argc, char *argv[]) { + // 启动分段计时: 每段耗时写入日志(带累计值), 用于定位启动慢的具体环节 + QElapsedTimer startupTimer; + startupTimer.start(); + auto mark = [&startupTimer](const char *stage) { + LogManager::instance()->logInfo( + QString("[启动计时] %1 - 累计 %2 ms").arg(QString::fromUtf8(stage)).arg(startupTimer.elapsed())); + }; + QApplication a(argc, argv); a.setWindowIcon(QIcon(":resources/images/ChangeCode.png")); + // 单实例保护: 双开会并发互写日志文件、并发覆盖 config.ini, 还会重复弹出升级提示。 + // QLockFile 持有方进程崩溃后残留锁可被下次启动自动接管(按锁内 PID 探测存活) + const QString lockPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + + "/UFT30ChangeCode.lock"; + QLockFile lockFile(lockPath); + if (!lockFile.tryLock(0)) { + LogManager::instance()->logWarning("检测到已有实例在运行, 本次启动退出"); + QMessageBox::warning(nullptr, "程序已在运行", + "UFT30 Change Code 已经在运行中,请勿重复启动。"); + return 0; + } + + // 启动画面: main() 最先显示, 覆盖后续主窗口首帧的小几何闪现("跳动框"), + // 主窗口显示完成后由 finish() 自动关闭 + QPixmap splashPix(640, 400); + splashPix.fill(QColor(0x27, 0x43, 0x5B)); + { + QPainter p(&splashPix); + p.setRenderHint(QPainter::Antialiasing); + QPixmap icon(":resources/images/ChangeCode.png"); + if (!icon.isNull()) { + p.drawPixmap((640 - 128) / 2, 96, 128, 128, icon); + } + QFont titleFont = p.font(); + titleFont.setPixelSize(30); + titleFont.setBold(true); + p.setFont(titleFont); + p.setPen(Qt::white); + p.drawText(QRect(0, 248, 640, 44), Qt::AlignHCenter, + QString("UFT30 Change Code %1").arg(Version::getVersionString())); + QFont subFont = p.font(); + subFont.setPixelSize(16); + p.setFont(subFont); + p.drawText(QRect(0, 302, 640, 28), Qt::AlignHCenter, QString("正在启动...")); + } + QSplashScreen splash(splashPix); + // 置顶显示: 启动画面默认非 topmost, 期间出现的任何窗口(主窗口半透明首帧、 + // 系统提示等)都可能绘制到它之上形成"残影", 置顶后可完全覆盖 + splash.setWindowFlag(Qt::WindowStaysOnTopHint, true); + splash.show(); + // 排除用户输入事件: 启动阶段积压的鼠标/键盘事件若被分发, 可能触发意料之外的 + // 焦点/点击行为(例如误点即将出现的窗口), 只处理绘制类事件保证 splash 正常上屏 + a.processEvents(QEventLoop::ExcludeUserInputEvents); + // 安装全局过滤器:所有页面文字 / 表格树单元格允许用户复制 TextSelectableFilter textSelectableFilter; a.installEventFilter(&textSelectableFilter); + // 日志初始化提前到启动最前: 此后所有分段耗时都能落盘, 便于排查启动瓶颈 + LogManager::instance()->initLogConfig(); + LogManager::instance()->initLogFile(); + mark("QApplication 就绪"); + eApp->init(); - + mark("ElaApplication::init 完成"); + + // 应用上次保存的主题模式(未保存过时为夜间模式, 与 Ela 默认一致): + // 必须在主窗口构造前设置, 保证主窗口首帧即为正确主题, 避免启动后闪色。 + // 此前主题从未被应用(设置页 onThemeChanged 为空), 界面始终是 Ela 默认夜间模式 + { + QSettings settings("UFT30", "ChangeCode"); + const QString theme = settings.value("Theme", "夜间模式").toString(); + ElaTheme::getInstance()->setThemeMode( + theme == QString::fromUtf8("日间模式") ? ElaThemeType::Light : ElaThemeType::Dark); + mark("主题模式应用完成"); + } + QString basePath = QCoreApplication::applicationDirPath(); ConfigManager::instance()->loadConfig(basePath); - - MainWindow w; - w.showMaximized(); - return a.exec(); + mark("config.ini 加载完成"); + + int exitCode = 0; + // 启动异常兜底: MainWindow 构造涉及 8 页面 + 缓存库 + 索引扫描, 任何一环抛出 + // 未捕获异常都会直接 std::terminate 无提示退出; 这里统一转弹窗 + 日志后有序退出 + try { + MainWindow w; + mark("MainWindow 构造完成(8 页面 + 缓存库)"); + + // show 前把窗口几何预设为屏幕可用区域: 使 native 窗口创建即为最大化尺寸, + // 避免 showMaximized 首帧从默认小尺寸几何跳变到最大化("跳动框") + w.setGeometry(QGuiApplication::primaryScreen()->availableGeometry()); + +#ifdef Q_OS_WIN + // 禁用 DWM 窗口过渡动画: 最大化显示时不再从旧几何帧动画跳变, 消除首帧"闪动框"。 + // 必须放在 setGeometry 之后: winId() 会提前创建 native 窗口, 若在几何预设前调用, + // native 窗口将以默认小尺寸创建, setGeometry 的预设效果被削弱 + HWND hwnd = reinterpret_cast(w.winId()); + BOOL disableTransitions = TRUE; + DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, + &disableTransitions, sizeof(disableTransitions)); +#endif + + w.showMaximized(); + mark("窗口已显示"); + splash.finish(&w); // 主窗口已上屏, 关闭启动画面 + exitCode = a.exec(); + } catch (const std::exception &e) { + splash.close(); + LogManager::instance()->logError( + QString("启动阶段未捕获异常: %1").arg(QString::fromUtf8(e.what()))); + QMessageBox::critical(nullptr, "启动失败", + QString("程序初始化发生异常: %1\n详情请查看日志文件。").arg(QString::fromUtf8(e.what()))); + exitCode = 1; + } catch (...) { + splash.close(); + LogManager::instance()->logError("启动阶段未捕获未知异常"); + QMessageBox::critical(nullptr, "启动失败", + "程序初始化发生未知异常。\n详情请查看日志文件。"); + exitCode = 1; + } + return exitCode; } diff --git a/src/components/elasearchedit.cpp b/src/components/elasearchedit.cpp index f6589fb..3a41b62 100644 --- a/src/components/elasearchedit.cpp +++ b/src/components/elasearchedit.cpp @@ -83,27 +83,14 @@ void ElaSearchEdit::setPlaceholderText(const QString& placeholderText) m_lineEdit->setPlaceholderText(placeholderText); } -void ElaSearchEdit::setFixedSize(const QSize& size) -{ - m_lineEdit->setFixedSize(size.width() - 35, size.height()); - m_searchBtn->setFixedSize(35, size.height()); - QWidget::setFixedSize(size); -} - -void ElaSearchEdit::setFixedSize(int w, int h) +void ElaSearchEdit::setSearchEditSize(int w, int h) { + // 35 = 搜索按钮宽度; 内部控件与整体尺寸联动 m_lineEdit->setFixedSize(w - 35, h); m_searchBtn->setFixedSize(35, h); QWidget::setFixedSize(w, h); } -void ElaSearchEdit::setFixedHeight(int h) -{ - m_lineEdit->setFixedHeight(h); - m_searchBtn->setFixedSize(35, h); - QWidget::setFixedHeight(h); -} - bool ElaSearchEdit::isExactMatch() const { return m_exactMatch; diff --git a/src/components/elasearchedit.h b/src/components/elasearchedit.h index ee5de62..9445cd7 100644 --- a/src/components/elasearchedit.h +++ b/src/components/elasearchedit.h @@ -18,10 +18,11 @@ public: QString text() const; void setText(const QString& text); void setPlaceholderText(const QString& placeholderText); - void setFixedSize(const QSize& size); - void setFixedSize(int w, int h); - void setFixedHeight(int h); - + // 同时设定输入框/按钮/整体尺寸。不遮蔽 QWidget::setFixedSize: + // 原同名遮蔽导致外部按 QWidget 指针调用 setFixedSize 时只缩了整体、 + // 内部输入框与按钮宽度不联动, 语义易混淆, 故改用显式命名 + void setSearchEditSize(int w, int h); + bool isExactMatch() const; void setExactMatch(bool exact); diff --git a/src/components/interdialog.cpp b/src/components/interdialog.cpp index 8364f6f..87b026b 100644 --- a/src/components/interdialog.cpp +++ b/src/components/interdialog.cpp @@ -79,6 +79,11 @@ void InterDialog::setMessage(const QString& message) void InterDialog::setCentralWidget(QWidget* centralWidget) { + // 空指针与自传防护: 传入空时直接返回; 传入当前已持有的指针时, + // 下面的 delete _centralWidget 会把新指针一并悬空, L90 解引用即崩溃 + if (centralWidget == nullptr || centralWidget == _centralWidget) { + return; + } _mainLayout->removeWidget(_centralWidget); delete _messageLabel; _messageLabel = nullptr; diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 5686732..6200c05 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -9,6 +9,7 @@ #include "utils/logmanager.h" #include "utils/Constants.h" #include "utils/versionchecker.h" +#include "utils/datacache.h" #include #include #include @@ -20,10 +21,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -37,25 +40,28 @@ namespace { MainWindow::MainWindow(QWidget *parent) : ElaWindow(parent) , m_pythonRunner(new PythonRunner(this)) - , m_iconsSet(false) - , m_navigationButton(nullptr) - , m_isNavigationExpanded(true) , m_businessConvertPage(nullptr) , m_batchConvertPage(nullptr) , m_nonLsConvertPage(nullptr) , m_activeKind(ConvertKind::LS) , m_totalFunctions(0) - , m_currentFunctionIndex(0) , m_lastProgress(0) + , m_taskActive(false) { + QElapsedTimer t; + t.start(); initWindow(); initContent(); - - LogManager::instance()->initLogConfig(); // 先读取 change_code.ini 的 [log] 配置,再开始写日志 - LogManager::instance()->initLogFile(); + LogManager::instance()->logInfo( + QString("[启动计时] initWindow+initContent(8 页面构造) 耗时 %1 ms").arg(t.elapsed())); + + // 日志初始化已在 main.cpp 中(splash 之前)完成, 此处不再重复调用 LogManager::instance()->logInfo("应用程序启动"); - + + t.restart(); loadConfigCache(); + LogManager::instance()->logInfo( + QString("[启动计时] loadConfigCache(缓存库打开+校验) 耗时 %1 ms").arg(t.elapsed())); m_pythonRunner->setRunMode(PythonRunner::UsePackagedExe); @@ -73,21 +79,28 @@ MainWindow::MainWindow(QWidget *parent) connect(m_pythonRunner, &PythonRunner::finished, this, &MainWindow::onPythonRunnerFinished); connect(m_pythonRunner, &PythonRunner::standardOutput, this, &MainWindow::onPythonRunnerOutput); connect(m_pythonRunner, &PythonRunner::standardError, this, &MainWindow::onPythonRunnerError); + // start() 异步化后, exe 无法启动(FailedToStart)不会经由日志输出可见, 必须由此收尾恢复 UI + connect(m_pythonRunner, &PythonRunner::errorOccurred, this, &MainWindow::onPythonRunnerProcessError); connect(this, &ElaWindow::navigationNodeClicked, this, &MainWindow::onNavigationNodeClicked); - QTimer::singleShot(100, this, &MainWindow::updateNavigationButtonIcon); - // 启动后延迟检查版本更新 QTimer::singleShot(3000, this, &MainWindow::checkForUpdates); + + // 启动即异步预热功能查询缓存(singleShot 0: 先让首帧渲染, 再提交线程池, 不阻塞启动): + // 否则 uft3 缓存要等用户首次进入查询页才在主线程同步重建(2 万+条可达 3s 卡顿); + // uf20 启动期存在性检查虽会触发预热, 此处统一兜底(已就绪/在途时内部幂等跳过) + QTimer::singleShot(0, this, [] { + DataCache::instance()->warmupFunctionCacheAsync("uf20"); + DataCache::instance()->warmupFunctionCacheAsync("uft3"); + }); } void MainWindow::loadConfigCache() { LogManager::instance()->log("正在加载配置缓存..."); - // 1. 加载 config.ini 到内存缓存(ConfigManager::loadConfig 之前从未被调用过) - ConfigManager::instance()->loadConfig(QCoreApplication::applicationDirPath()); + // config.ini 已在 main.cpp 启动早期加载到 ConfigManager 内存缓存, 此处不再重复读取 UF2ConfigReader uf2Reader; bool uf2Loaded = uf2Reader.loadAllUF20Config(); @@ -127,8 +140,8 @@ void MainWindow::initWindow() { setWindowTitle(QString("UFT30 Change Code %1").arg(Version::getVersionString())); setWindowIcon(QIcon(":/resources/images/ChangeCode.png")); - resize(900, 600); - setMinimumSize(800, 500); + // 不设置初始 resize/minimumSize: 原生窗口将以最大化几何创建, + // 避免先按 900x600 建窗再跳变到最大化造成首帧"跳动框" setUserInfoCardVisible(false); // 隐藏返回和前进按钮 @@ -138,24 +151,41 @@ void MainWindow::initWindow() } void MainWindow::initContent() -{ +{ + // 逐项计时: 定位 8 个页面的构造耗时分布, 找出启动慢的具体页面 + QElapsedTimer pt; + pt.start(); + auto markPage = [this, &pt](const char *name) { + LogManager::instance()->logInfo( + QString("[启动计时] %1 耗时 %2 ms").arg(QString::fromUtf8(name)).arg(pt.elapsed())); + pt.restart(); + }; + addExpanderNode("功能转码", m_funcConvertKey, ElaIconType::FileCode); addPageNode("业务转码", createBusinessConvertPage(), m_funcConvertKey, ElaIconType::ArrowsRepeat); + markPage("业务转码(含全量/按需两页)"); addPageNode("代码检索", createCodeSearchPage(), m_funcConvertKey, ElaIconType::Cubes); + markPage("代码检索"); addExpanderNode("功能查询", m_funcSearchKey, ElaIconType::MagnifyingGlass); addPageNode("UF20功能查询", createUF20FunctionSearchPage(), m_funcSearchKey, ElaIconType::MagnifyingGlassPlus); + markPage("UF20功能查询"); addPageNode("UFT3功能查询", createUFT3FunctionSearchPage(), m_funcSearchKey, ElaIconType::MagnifyingGlassPlus); + markPage("UFT3功能查询"); expandNavigationNode(m_funcConvertKey); addExpanderNode("数据映射", m_dataMappingKey, ElaIconType::TableList); addPageNode("表替换", createTableMappingPage(), m_dataMappingKey, ElaIconType::TableColumns); + markPage("表替换"); addPageNode("函数替换", createFunctionMappingPage(), m_dataMappingKey, ElaIconType::Function); + markPage("函数替换"); addPageNode("元数据更新", createMetadataUpdatePage(), ElaIconType::ArrowsRotate); - + markPage("元数据更新"); + addFooterNode("帮助", m_helpKey, 0, ElaIconType::CircleQuestion); addFooterNode("关于", m_aboutKey, 0, ElaIconType::Info); addFooterNode("设置", createSettingsPage(), m_settingKey, 0, ElaIconType::GearComplex); + markPage("设置+导航节点"); } QWidget* MainWindow::createBusinessConvertPage() @@ -181,25 +211,68 @@ QWidget* MainWindow::createCodeSearchPage() return _codeSearchPage; } -QWidget* MainWindow::createFunctionSearchPage() -{ - return new FunctionSearchPage; -} - QWidget* MainWindow::createUF20FunctionSearchPage() { UF20FunctionSearchPage *page = new UF20FunctionSearchPage; - // LS_ 开头功能 → 全量转码工作台;其余功能 → 按需转码工作台 - connect(page, &UF20FunctionSearchPage::addLsFunctionToConvert, - m_batchConvertPage, &BatchConvertPage::addFunction); - connect(page, &UF20FunctionSearchPage::addNonLsFunctionToConvert, - m_nonLsConvertPage, &NonLsConvertPage::addFunction); + // 一键添加(双击/右键共用): 静默分流加入两个工作台, 结束后合并为一个提示框("全量转码"/"按需转码"加粗) + connect(page, &UF20FunctionSearchPage::addFunctionsToConvert, + this, [this](const QStringList &lsNames, const QStringList &nonLsNames) { + int addedLs = 0, skipLs = 0, addedNonLs = 0, skipNonLs = 0; + bool saveFailed = false; + if (!lsNames.isEmpty()) { + const QPair r = m_batchConvertPage->addFunctionsBatch(lsNames, false); + if (r.first < 0) { + saveFailed = true; // 保存失败已由工作台弹窗警告 + } else { + addedLs = r.first; + skipLs = r.second; + } + } + if (!nonLsNames.isEmpty()) { + const QPair r = m_nonLsConvertPage->addFunctionsBatch(nonLsNames, false); + if (r.first < 0) { + saveFailed = true; + } else { + addedNonLs = r.first; + skipNonLs = r.second; + } + } + if (saveFailed) { + return; + } + QStringList parts; + if (addedLs > 0) { + QString part = QString("已将 %1 个功能加入全量转码").arg(addedLs); + if (skipLs > 0) { + part += QString("(另有 %1 个已存在,已跳过)").arg(skipLs); + } + parts.append(part); + } + if (addedNonLs > 0) { + QString part = QString("已将 %1 个功能加入按需转码").arg(addedNonLs); + if (skipNonLs > 0) { + part += QString("(另有 %1 个已存在,已跳过)").arg(skipNonLs); + } + parts.append(part); + } + if (!parts.isEmpty()) { + QMessageBox box(QMessageBox::Information, "提示", parts.join(","), + QMessageBox::Ok, this); + box.setTextFormat(Qt::RichText); // 富文本使"全量转码"/"按需转码"加粗生效 + box.exec(); + } else if (skipLs + skipNonLs > 0) { + QMessageBox::information(this, "提示", + QString("所选 %1 个功能均已在转码列表中").arg(skipLs + skipNonLs)); + } + }); return page; } QWidget* MainWindow::createUFT3FunctionSearchPage() { - return new UFT3FunctionSearchPage; + UFT3FunctionSearchPage *page = new UFT3FunctionSearchPage; + // UFT3 页仅作查询用途, 不接入转码工作台 + return page; } QWidget* MainWindow::createSettingsPage() @@ -256,18 +329,17 @@ void MainWindow::beginConvert(ConvertKind kind) engineArgs = m_nonLsConvertPage ? m_nonLsConvertPage->preparedArguments() : QStringList(); m_pythonRunner->setArguments(engineArgs); m_totalFunctions = m_nonLsConvertPage ? m_nonLsConvertPage->getFunctionCount() : 0; - m_functionList = m_nonLsConvertPage ? m_nonLsConvertPage->getFunctionList() : QStringList(); } else { // 全量转码:沿用原无参调用,显式清空历史参数 m_pythonRunner->setArguments(QStringList()); m_totalFunctions = m_batchConvertPage ? m_batchConvertPage->getFunctionCount() : 0; - m_functionList = m_batchConvertPage ? m_batchConvertPage->getFunctionList() : QStringList(); } m_activeKind = kind; - m_currentFunctionIndex = 0; // 进度基准:页面在开始时已将进度条置为 20(配置加载完成) m_lastProgress = 20; + // 新任务开始: 清空上一任务的失败计数, 避免误把旧 FAIL 记入本次收尾判定 + m_failCount = 0; LogManager::instance()->log("========================================"); LogManager::instance()->log(QString("开始执行 %1 代码转换...") @@ -281,8 +353,13 @@ void MainWindow::beginConvert(ConvertKind kind) if (!m_pythonRunner->start()) { LogManager::instance()->logError("错误: 无法启动转换进程!"); + QMessageBox::warning(this, "失败", + "转换进程启动失败,请检查转换引擎程序是否存在、被占用或被安全软件拦截"); return; } + // start() 已受理启动流程(同步启动失败已在 start() 内检测并返回 false): + // 启动结果由 finished / errorOccurred 信号异步上报 + m_taskActive = true; // 本页进入运行态;另一 Tab 禁止开始,避免并发冲突 if (kind == ConvertKind::NonLs) { @@ -304,7 +381,9 @@ void MainWindow::beginConvert(ConvertKind kind) void MainWindow::onConvertStop() { - if (m_pythonRunner->isRunning()) { + // 用宽判定: 启动窗口期(state==Starting, 杀软扫描可达 3s) isRunning() 为 false, + // 原判据导致点"停止转换"静默无效, 任务照常执行且无任何反馈 + if (!m_pythonRunner->isActive()) { LogManager::instance()->log("正在停止转换进程..."); m_pythonRunner->stop(); } @@ -312,17 +391,42 @@ void MainWindow::onConvertStop() void MainWindow::onPythonRunnerFinished(int exitCode, QProcess::ExitStatus exitStatus) { + // 幂等守卫: 一次任务只允许一次收尾, 重复触发的收尾信号必须跳过。 + // 注: Windows 上启动失败(FailedToStart)的错误信号在 start() 调用栈内同步发出, 此时 + // m_taskActive 尚未置位会在此被跳过——该场景由 start() 返回 false 交回 beginConvert + // 启动失败分支收尾, 属预期路径; 正常任务由 finished 信号触发唯一一次收尾 + if (!m_taskActive) { + return; + } + m_taskActive = false; + + // 成败判定: 引擎正常退出(退出码 0)且未检测到 FAIL 行才算成功 + const bool success = (exitStatus == QProcess::NormalExit && exitCode == 0 && m_failCount == 0); + // 失败 UI 文案: 有失败计数时显示失败文件数(信息量更大), 否则为引擎异常退出 + const QString failDetail = m_failCount > 0 + ? QString("转换失败(%1 个文件转换失败)").arg(m_failCount) + : "转换失败"; + // 完成态回到发起任务的 Tab,并解除另一 Tab 的互斥限制 if (m_activeKind == ConvertKind::NonLs) { if (m_nonLsConvertPage) { - m_nonLsConvertPage->onConvertFinished(); + if (success) { + m_nonLsConvertPage->onConvertFinished(); + } else { + // 失败收尾: 进度条保持当前进度, 状态文字提示"转换失败"(不再误报 100%+完成) + m_nonLsConvertPage->onConvertFailed(failDetail); + } } if (m_batchConvertPage) { m_batchConvertPage->setTaskBusy(false); } } else { if (m_batchConvertPage) { - m_batchConvertPage->onConvertFinished(); + if (success) { + m_batchConvertPage->onConvertFinished(); + } else { + m_batchConvertPage->onConvertFailed(failDetail); + } } if (m_nonLsConvertPage) { m_nonLsConvertPage->setTaskBusy(false); @@ -330,12 +434,16 @@ void MainWindow::onPythonRunnerFinished(int exitCode, QProcess::ExitStatus exitS } LogManager::instance()->log("========================================"); - if (exitStatus == QProcess::NormalExit && exitCode == 0) { + if (success) { LogManager::instance()->log("✓ 代码转换完成!"); if (_codeSearchPage) { _codeSearchPage->refreshIndexSilent(); } QMessageBox::information(this, "完成", "代码转换任务已成功完成!"); + } else if (m_failCount > 0) { + // 引擎正常退出但检测到 FAIL 行: 全量转码个别文件失败时退出码仍为 0 + LogManager::instance()->logError(QString("✗ 转换失败! %1 个文件转换失败").arg(m_failCount)); + QMessageBox::warning(this, "失败", failDetail); } else { LogManager::instance()->logError(QString("✗ 转换失败! 退出码: %1").arg(exitCode)); QMessageBox::warning(this, "失败", QString("代码转换任务失败,退出码: %1").arg(exitCode)); @@ -343,6 +451,19 @@ void MainWindow::onPythonRunnerFinished(int exitCode, QProcess::ExitStatus exitS LogManager::instance()->log("========================================"); } +void MainWindow::onPythonRunnerProcessError(QProcess::ProcessError error) +{ + // 仅收尾 FailedToStart: exe 无法启动时进程从未运行, 不能依赖日志输出, + // 必须立刻走失败收尾恢复两 Tab 的 UI 状态。 + // (注: Windows 上该信号在 start() 调用栈内同步发出, 此时 m_taskActive 尚未置位, + // onPythonRunnerFinished 会跳过收尾, 由 beginConvert 的启动失败分支负责; 本槽 + // 保留以覆盖异步上报路径(如 Unix 下 exec 失败), 此时幂等守卫放行, 由本槽收尾) + if (error == QProcess::FailedToStart) { + LogManager::instance()->logError("错误: 转换进程启动失败(FailedToStart)!"); + onPythonRunnerFinished(-1, QProcess::CrashExit); + } +} + void MainWindow::onPythonRunnerOutput(const QString &output) { if (output.trimmed().isEmpty()) { @@ -357,6 +478,29 @@ void MainWindow::onPythonRunnerOutput(const QString &output) int targetProgress = -1; QString status; + // 0) 失败标记检测(优先于进度解析): 引擎个别文件 FAIL 时流水线仍以退出码 0 结束 + // (15:04 日志实证: [1/1] FAIL + 转换流水线执行成功!), 必须从输出行识别失败数, + // 收尾时按 m_failCount 判定成败, 否则会误报"转换完成" + int failHits = 0; + static const QRegularExpression failRegExp(R"(\[\s*\d+/\d+\]\s+FAIL\s+\S+)"); + auto fit = failRegExp.globalMatch(text); + while (fit.hasNext()) { + fit.next(); + ++failHits; + } + m_failCount += failHits; + + // 引擎失败汇总行(按需转码模式): "非LS批量转码完成: X/Y 通过, Z 失败", 计数取更权威者 + static const QRegularExpression summaryRegExp( + R"(批量转码完成:\s*\d+/\d+\s*通过,\s*(\d+)\s*失败)"); + QRegularExpressionMatch sm = summaryRegExp.match(text); + if (sm.hasMatch()) { + const int failed = sm.captured(1).toInt(); + if (failed > m_failCount) { + m_failCount = failed; + } + } + // 1) 优先解析工具显式输出的 progress:xx / progress=xx static const QRegularExpression progressRegExp( R"(progress\s*[:=]\s*(\d+))", QRegularExpression::CaseInsensitiveOption); @@ -367,18 +511,21 @@ void MainWindow::onPythonRunnerOutput(const QString &output) } else { // 2) 解析并行翻译进度 [ n/m](翻译阶段映射到 55~78) // 注意时间戳 [2026-07-17 ...] 不含 '/' 不会误匹配 - 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] - } - if (stepMatch.hasMatch()) { - int cur = stepMatch.captured(1).toInt(); - int tot = stepMatch.captured(2).toInt(); - if (tot > 0) { - targetProgress = 55 + (cur * 23) / tot; - status = QString("正在翻译文件 %1/%2").arg(cur).arg(tot); + // 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] + } + if (stepMatch.hasMatch()) { + int cur = stepMatch.captured(1).toInt(); + int tot = stepMatch.captured(2).toInt(); + if (tot > 0) { + targetProgress = 55 + (cur * 23) / tot; + status = QString("正在翻译文件 %1/%2").arg(cur).arg(tot); + } } } @@ -409,6 +556,11 @@ void MainWindow::onPythonRunnerOutput(const QString &output) } } + // FAIL 行的即时状态提示: 覆盖其他阶段标记, 让用户立刻看到失败反馈 + if (failHits > 0) { + status = "检测到文件转换失败"; + } + // 进度单调递增:只有更大的进度才会推进进度条,避免"一下长一下短" if (targetProgress > m_lastProgress) { m_lastProgress = targetProgress; @@ -432,97 +584,21 @@ void MainWindow::onPythonRunnerError(const QString &error) } } -void MainWindow::updateNavigationButtonIcon() -{ - // 使用资源文件中的图标 - QIcon leftIcon(":/resources/images/zhedie_left.png"); - - if (leftIcon.isNull()) { - return; - } - - // 查找所有 ElaToolButton - QList allButtons = this->findChildren(QString(), Qt::FindChildrenRecursively); - - // 尝试查找导航按钮 - 可能在不同位置 - for (int i = 0; i < allButtons.size(); i++) { - ElaToolButton* btn = allButtons[i]; - QString objName = btn->objectName(); - - // 检查是否是导航按钮(可能的名称) - if (objName.contains("Nav", Qt::CaseInsensitive) || - objName.contains("Menu", Qt::CaseInsensitive) || - objName.contains("Navigation", Qt::CaseInsensitive)) { - m_navigationButton = btn; - break; - } - } - - // 如果没有找到有名称的导航按钮,使用第一个可见按钮 - if (!m_navigationButton) { - for (ElaToolButton* btn : allButtons) { - if (btn->isVisible()) { - m_navigationButton = btn; - break; - } - } - } - - if (m_navigationButton) { - m_navigationButton->setProperty("ElaIconType", ""); - m_navigationButton->setIcon(leftIcon); - m_navigationButton->setIconSize(QSize(24, 24)); - m_navigationButton->update(); - - // 先断开可能存在的连接,然后重新连接,避免多次连接 - disconnect(m_navigationButton, &ElaToolButton::clicked, this, &MainWindow::onNavigationButtonClicked); - connect(m_navigationButton, &ElaToolButton::clicked, this, &MainWindow::onNavigationButtonClicked); - } -} - -bool MainWindow::eventFilter(QObject *obj, QEvent *event) -{ - if (!m_iconsSet && event->type() == QEvent::Show) { - // 窗口显示后尝试设置图标 - updateNavigationButtonIcon(); - m_iconsSet = true; - } - return ElaWindow::eventFilter(obj, event); -} - -void MainWindow::onNavigationButtonClicked() -{ - if (!m_navigationButton) { - return; - } - - QIcon leftIcon(":/resources/images/zhedie_left.png"); - QIcon rightIcon(":/resources/images/zhedie_right.png"); - - // 使用状态变量来判断当前状态,而不是依赖 cacheKey - if (m_isNavigationExpanded) { - // 当前是展开状态,切换到折叠状态 - m_navigationButton->setIcon(rightIcon); - m_isNavigationExpanded = false; - } else { - // 当前是折叠状态,切换到展开状态 - m_navigationButton->setIcon(leftIcon); - m_isNavigationExpanded = true; - } - m_navigationButton->update(); -} - void MainWindow::checkForUpdates() { - auto *checker = new VersionChecker(this); - connect(checker, &VersionChecker::newVersionAvailable, this, &MainWindow::onNewVersionFound); - connect(checker, &VersionChecker::checkError, this, [](const QString &msg) { - LogManager::instance()->logInfo("版本检查: " + msg); - }); - connect(checker, &VersionChecker::noUpdateNeeded, this, []() { - LogManager::instance()->logInfo("版本检查: 已是最新版本"); - }); - checker->check(UPDATE_JSON, Version::getVersionString()); + // 复用成员: 原每次点击都 new VersionChecker(this) 累积泄漏; VersionChecker + // 已加 m_checking 防重入, 复用同一实例安全 + if (!m_versionChecker) { + m_versionChecker = new VersionChecker(this); + connect(m_versionChecker, &VersionChecker::newVersionAvailable, this, &MainWindow::onNewVersionFound); + connect(m_versionChecker, &VersionChecker::checkError, this, [](const QString &msg) { + LogManager::instance()->logInfo("版本检查: " + msg); + }); + connect(m_versionChecker, &VersionChecker::noUpdateNeeded, this, []() { + LogManager::instance()->logInfo("版本检查: 已是最新版本"); + }); + } + m_versionChecker->check(UPDATE_JSON, Version::getVersionString()); } void MainWindow::onNewVersionFound(const QString &latestVersion, const QString &releaseNotes) diff --git a/src/mainwindow/mainwindow.h b/src/mainwindow/mainwindow.h index 6fd938a..ebacc20 100644 --- a/src/mainwindow/mainwindow.h +++ b/src/mainwindow/mainwindow.h @@ -9,7 +9,6 @@ #include "src/pages/batchconvert/batchconvertpage.h" #include "src/pages/nonlsconvert/nonlsconvertpage.h" #include "src/pages/codesearch/codesearchpage.h" -#include "src/pages/functionsearch/functionsearchpage.h" #include "src/pages/functionsearch/uf20functionsearchpage.h" #include "src/pages/functionsearch/uft3functionsearchpage.h" @@ -20,6 +19,8 @@ #include "src/pages/datamapping/functionmappingpage.h" #include "src/utils/version.h" +class VersionChecker; // 前置声明: 成员指针无需完整类型 + class MainWindow : public ElaWindow { Q_OBJECT @@ -34,7 +35,6 @@ private: QWidget* createBusinessConvertPage(); QWidget* createCodeSearchPage(); - QWidget* createFunctionSearchPage(); QWidget* createUF20FunctionSearchPage(); QWidget* createUFT3FunctionSearchPage(); QWidget* createSettingsPage(); @@ -52,31 +52,28 @@ private: void onConvertStop(); void beginConvert(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); void checkForUpdates(); void onNewVersionFound(const QString &latestVersion, const QString &releaseNotes); - void updateNavigationButtonIcon(); - bool eventFilter(QObject *obj, QEvent *event) override; - void onNavigationButtonClicked(); void onNavigationNodeClicked(ElaNavigationType::NavigationNodeType nodeType, QString nodeKey); - + PythonRunner *m_pythonRunner; - bool m_iconsSet; - ElaToolButton* m_navigationButton; - bool m_isNavigationExpanded; BusinessConvertPage *m_businessConvertPage; BatchConvertPage *m_batchConvertPage; NonLsConvertPage *m_nonLsConvertPage; CodeSearchPage *_codeSearchPage; + VersionChecker *m_versionChecker = nullptr; // 复用: 每次"检查更新"都 new 会累积泄漏 ConvertKind m_activeKind; int m_totalFunctions; - int m_currentFunctionIndex; int m_lastProgress; // 已上报的最大进度,保证进度单调递增,避免进度条来回跳动 - QStringList m_functionList; + int m_failCount = 0; // 从引擎输出识别到的失败文件数(引擎个别文件 FAIL 时退出码仍为 0) + bool m_taskActive; // 是否有转换任务在途: 幂等收尾守卫(见 onPythonRunnerFinished) QString m_helpKey; QString m_aboutKey; QString m_settingKey; diff --git a/src/metadataupdate/filedb.cpp b/src/metadataupdate/filedb.cpp index 85d1604..1365d85 100644 --- a/src/metadataupdate/filedb.cpp +++ b/src/metadataupdate/filedb.cpp @@ -1,15 +1,12 @@ #include "filedb.h" -FileDB* FileDB::m_instance = nullptr; - FileDB::FileDB(QObject *parent) : QObject(parent) { } FileDB* FileDB::instance() { - if (!m_instance) { - m_instance = new FileDB(); - } - return m_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 index ad95954..1daf855 100644 --- a/src/metadataupdate/filedb.h +++ b/src/metadataupdate/filedb.h @@ -9,12 +9,9 @@ class FileDB : public QObject Q_OBJECT public: static FileDB* instance(); - - QStringList uft3RealFunctionList; - + private: explicit FileDB(QObject *parent = nullptr); - static FileDB* m_instance; }; #endif // FILEDB_H \ No newline at end of file diff --git a/src/metadataupdate/interface_scanner.h b/src/metadataupdate/interface_scanner.h index 6db530a..66b275b 100644 --- a/src/metadataupdate/interface_scanner.h +++ b/src/metadataupdate/interface_scanner.h @@ -5,6 +5,7 @@ #include #include #include +#include // ============================================================================ // 接口扫描器的公共类型与抽象接口 @@ -78,6 +79,9 @@ class IInterfaceScanner { public: virtual ~IInterfaceScanner() = default; + // 设置外部取消标志(指向 MetadataProcessor 的原子标志,生命周期由调用方保证) + void setCancelFlag(std::atomic_bool* flag) { m_cancelFlag = flag; } + // 解析单个接口文件首行属性,得到接口信息 virtual InterfaceInfo loadInterface(const QString& filePath) = 0; @@ -93,6 +97,13 @@ public: // 构建模块编码映射(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); } + +private: + std::atomic_bool* m_cancelFlag = nullptr; // 外部取消标志,非拥有指针 }; #endif // INTERFACE_SCANNER_H diff --git a/src/metadataupdate/metadataprocessor.cpp b/src/metadataupdate/metadataprocessor.cpp index a764165..6806cd8 100644 --- a/src/metadataupdate/metadataprocessor.cpp +++ b/src/metadataupdate/metadataprocessor.cpp @@ -279,7 +279,7 @@ double dirMtime(const QString& path) // 将缓存库 (name -> data JSON 字符串) 行集还原为 QJsonObject(key -> 子对象/子数组) // srcMtime: 源目录最新修改时间(多源时调用方相加), 写入 _meta.src_mtime 供缓存失效判断。 -void MetadataProcessor::saveRowsFromJson(const QString& db, const QString& table, +bool MetadataProcessor::saveRowsFromJson(const QString& db, const QString& table, const QJsonObject& resultJson, double srcMtime) { QMap rows; @@ -288,8 +288,9 @@ void MetadataProcessor::saveRowsFromJson(const QString& db, const QString& table rows[it.key()] = QString::fromUtf8(QJsonDocument(val).toJson(QJsonDocument::Compact)); } if (rows.isEmpty()) { - LogManager::instance()->logWarning(QString("元数据落库跳过 - %1.%2 为空").arg(db, table)); - return; + LogManager::instance()->logWarning( + QString("元数据落库跳过 - %1.%2 为空(源路径未配置或目录不存在?)").arg(db, table)); + return true; // 空数据集跳过不算失败(解析可能确实无数据) } // 注意: 此处不可调用 DataCache::initDatabase() 做前置可行性判断。 // initDatabase() 是"只读探测"(库文件不存在时返回 false), 若在此依赖它, @@ -300,8 +301,9 @@ void MetadataProcessor::saveRowsFromJson(const QString& db, const QString& table .arg(db, table).arg(rows.size()).arg(srcMtime, 0, 'f', 3)); if (!DataCache::instance()->saveDataset(db, table, rows, srcMtime)) { LogManager::instance()->logError(QString("元数据落库失败: %1.%2").arg(db, table)); - return; + return false; } + return true; } bool MetadataProcessor::processUf2Interfaces(const QString& basePath) @@ -321,6 +323,7 @@ bool MetadataProcessor::processUf2Interfaces(const QString& basePath) ctx.custJson = readJson(custJsonPath); Uf2Interface uf2; + uf2.setCancelFlag(&m_cancelRequested); // 注入取消标志,递归扫描可快速终止 // 解析结果直接落库 uf20.uf2_interface, 不再生成中间 JSON(uf2.json) bool ok = processInterfaces(&uf2, ctx, m_uf20Path, "uf2", false); return ok; @@ -340,6 +343,7 @@ bool MetadataProcessor::processUft3Interfaces(const QString& basePath) ctx.moduleGenerationCheck = m_moduleGenerationCheck; Uft3Interface uft3; + uft3.setCancelFlag(&m_cancelRequested); // 注入取消标志,递归扫描可快速终止 // 解析结果直接落库 uft3.uft3_interface / uft3.uft3_transcode, 不再生成中间 JSON(uft3.json/uft3Trans.json) bool ok = processInterfaces(&uft3, ctx, m_uft30Path, "uft3", true); return ok; @@ -351,17 +355,28 @@ bool MetadataProcessor::processInterfaces(IInterfaceScanner* scanner, const QString& cacheKey, bool isUft3) { - QList entries = scanner->collectFiles(dirPath, baseCtx); - (void)entries; // 全量模式下扫描在 scanDir 内完成, entries 仅供增量模式遍历使用 - - QMap moduleCodeMap = scanner->buildModuleCodeMap(dirPath, baseCtx); - (void)moduleCodeMap; - - // 不再落中间 JSON / __file_cache 表, 元数据更新改为每次全量重新扫描所有接口文件并落库覆盖。 const QString& db = isUft3 ? "uft3" : "uf20"; const QString interfaceTable = isUft3 ? Constants::Cache::TblUft3Interface : Constants::Cache::TblUf2Interface; - // 无增量基线(__file_cache 已移除), 始终全量重扫。 + // src_mtime 对齐 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; + } + } QJsonObject resultJson; QJsonObject transCodeJson; @@ -384,22 +399,29 @@ bool MetadataProcessor::processInterfaces(IInterfaceScanner* scanner, LogManager::instance()->log(QString("%1 全量扫描耗时: %2ms").arg(cacheKey).arg(timer.elapsed())); } - // 解析结果直接落库, 不再生成中间 JSON 文件 - // src_mtime 对齐 Python: UF2 接口源 = uf20 目录 + ufAcct20 目录(相加); - // UFT3 接口源 = 传入的工程目录 dirPath(更新 UFT3 时即 uft30 目录)。 - const double srcMtime = isUft3 ? dirMtime(dirPath) - : (dirMtime(m_uf20Path) + dirMtime(m_ufAcct20Path)); - if (isUft3) { - saveRowsFromJson("uft3", Constants::Cache::TblUft3Interface, resultJson, srcMtime); - if (!transCodeJson.isEmpty()) { - saveRowsFromJson("uft3", Constants::Cache::TblUft3TransCode, transCodeJson, srcMtime); - } - } else { - saveRowsFromJson("uf20", Constants::Cache::TblUf2Interface, resultJson, srcMtime); + // 协作式取消:页面析构时请求取消,此处跳过落库直接返回 + if (isCancelRequested()) { + LogManager::instance()->log(QString("%1 接口处理已取消,跳过落库").arg(cacheKey)); + return false; } - LogManager::instance()->log(QString("%1 接口处理完成,已落库: %2.%3").arg(cacheKey).arg(db, interfaceTable)); - return true; + // 解析结果直接落库, 不再生成中间 JSON 文件 + bool ok = true; + if (isUft3) { + ok = saveRowsFromJson("uft3", Constants::Cache::TblUft3Interface, resultJson, srcMtime) && ok; + if (!transCodeJson.isEmpty()) { + ok = saveRowsFromJson("uft3", Constants::Cache::TblUft3TransCode, transCodeJson, srcMtime) && ok; + } + } else { + ok = saveRowsFromJson("uf20", Constants::Cache::TblUf2Interface, resultJson, srcMtime) && ok; + } + + if (ok) { + LogManager::instance()->log(QString("%1 接口处理完成,已落库: %2.%3").arg(cacheKey).arg(db, interfaceTable)); + } else { + LogManager::instance()->logError(QString("%1 接口处理落库失败").arg(cacheKey)); + } + return ok; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2DataType() @@ -415,6 +437,10 @@ bool MetadataProcessor::processUf2DataType() 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()) { @@ -424,12 +450,12 @@ bool MetadataProcessor::processUf2DataType() } // 解析结果直接落库 uf20.uf2_datatype, 不再生成中间 JSON(uf2dataType.json) - saveRowsFromJson("uf20", Constants::Cache::TblUf2DataType, resultJson, dirMtime(m_uf20Path)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2StdFields() @@ -454,12 +480,21 @@ bool MetadataProcessor::processUf2StdFields() uf2datatypeMap[it.key()] = typeInfo; } } + if (uf2datatypeMap.isEmpty()) { + LogManager::instance()->logWarning( + "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()) { @@ -469,12 +504,12 @@ bool MetadataProcessor::processUf2StdFields() } // 解析结果直接落库 uf20.uf2_stdfield, 不再生成中间 JSON(uf2stdfield.json) - saveRowsFromJson("uf20", Constants::Cache::TblUf2StdField, resultJson, dirMtime(m_uf20Path)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2ErrNumber() @@ -490,6 +525,10 @@ bool MetadataProcessor::processUf2ErrNumber() 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()) { @@ -499,12 +538,12 @@ bool MetadataProcessor::processUf2ErrNumber() } // 解析结果直接落库 uf20.uf2_error, 不再生成中间 JSON(uf2Error.json) - saveRowsFromJson("uf20", Constants::Cache::TblUf2Error, resultJson, dirMtime(m_uf20Path)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2Constants() @@ -518,6 +557,10 @@ bool MetadataProcessor::processUf2Constants() 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()) { @@ -527,11 +570,11 @@ bool MetadataProcessor::processUf2Constants() } // 解析结果直接落库 uf20.uf2_constants, 不再生成中间 JSON(uf2constants.json) - saveRowsFromJson("uf20", Constants::Cache::TblUf2Constants, resultJson, dirMtime(m_uf20Path)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2MenuFunc() @@ -545,6 +588,10 @@ bool MetadataProcessor::processUf2MenuFunc() 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()) { @@ -555,13 +602,13 @@ bool MetadataProcessor::processUf2MenuFunc() // 解析结果只落库 uf20.uf2_menu_func(不再生成 uf2_menu 表与中间 JSON)。 // Python 侧 schema 也只定义 uf2_menu_func 一个数据集, 两者保持对齐。 - saveRowsFromJson("uf20", Constants::Cache::TblUf2MenuFunc, resultJson, dirMtime(m_uf20Path)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2Module() @@ -575,6 +622,10 @@ bool MetadataProcessor::processUf2Module() 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()) { @@ -584,11 +635,11 @@ bool MetadataProcessor::processUf2Module() } // 解析结果直接落库 uf20.uf2_module, 不再生成中间 JSON(uf2module.json) - saveRowsFromJson("uf20", Constants::Cache::TblUf2Module, resultJson, dirMtime(m_uf20Path)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUf2Table() @@ -603,17 +654,21 @@ bool MetadataProcessor::processUf2Table() 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(); } // 解析结果直接落库 uf20.uf2_table, 不再生成中间 JSON(uf2table.json) // src_mtime 对齐 Python: uf2_table 源 = uf20 目录 + 数据库设置目录(相加) - saveRowsFromJson("uf20", Constants::Cache::TblUf2Table, resultJson, - dirMtime(m_uf20Path) + dirMtime(m_uftDbSettPath)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUft3DataType() @@ -629,6 +684,10 @@ bool MetadataProcessor::processUft3DataType() 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()) { @@ -638,12 +697,12 @@ bool MetadataProcessor::processUft3DataType() } // 解析结果直接落库 uft3.uft3_datatype, 不再生成中间 JSON(uft3datatype.json) - saveRowsFromJson("uft3", Constants::Cache::TblUft3DataType, resultJson, dirMtime(m_uft30PubPath)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUft3StdFields() @@ -668,12 +727,21 @@ bool MetadataProcessor::processUft3StdFields() uf3datatypeMap[it.key()] = typeInfo; } } + if (uf3datatypeMap.isEmpty()) { + LogManager::instance()->logWarning( + "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()) { @@ -683,12 +751,12 @@ bool MetadataProcessor::processUft3StdFields() } // 解析结果直接落库 uft3.uft3_stdfield, 不再生成中间 JSON(uft3stdfields.json) - saveRowsFromJson("uft3", Constants::Cache::TblUft3StdField, resultJson, dirMtime(m_uft30PubPath)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } bool MetadataProcessor::processUft3Table() @@ -704,22 +772,37 @@ bool MetadataProcessor::processUft3Table() QJsonObject resultJson; for (const QString& key : resultMap.keys()) { + if (isCancelRequested()) { // 协作式取消: 页面析构后不再解析落库 + LogManager::instance()->log("UFT3.0 表结构处理已取消,跳过落库"); + return false; + } const QMap& tableInfo = resultMap[key]; QJsonObject tableJson; for (const QString& attrKey : tableInfo.keys()) { QVariant value = tableInfo[attrKey]; 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())); + } else { + tableJson[attrKey] = jv; + } } } resultJson[key] = tableJson; } // 解析结果直接落库 uft3.uft3_table, 不再生成中间 JSON(uft3Table.json) - saveRowsFromJson("uft3", Constants::Cache::TblUft3Table, resultJson, dirMtime(m_uft30PubPath)); + 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 true; + return saved; // 落库失败上抛, 不再恒报成功 } \ No newline at end of file diff --git a/src/metadataupdate/metadataprocessor.h b/src/metadataupdate/metadataprocessor.h index cdc60ee..857ac01 100644 --- a/src/metadataupdate/metadataprocessor.h +++ b/src/metadataupdate/metadataprocessor.h @@ -6,6 +6,7 @@ #include #include #include +#include #include "interface_scanner.h" @@ -20,6 +21,12 @@ public: void setForceFull(bool force); // 强制全量更新(兼容 UI;原版每次均全量解析,无增量缓存时不影响行为) + // 协作式取消:请求终止当前处理(页面析构时调用,避免后台任务持有 this 导致悬空) + void requestCancel() { m_cancelRequested.store(true, std::memory_order_relaxed); } + // 启动新任务前重置取消标志 + void resetCancel() { m_cancelRequested.store(false, std::memory_order_relaxed); } + bool isCancelRequested() const { return m_cancelRequested.load(std::memory_order_relaxed); } + bool processUf2StdFields(); bool processUf2DataType(); bool processUf2ErrNumber(); @@ -48,7 +55,7 @@ private: // 把解析得到的 QJsonObject(key -> 子对象) 直接落库到双库对应表(name 列=key, data 列=子对象JSON)。 // 不再生成任何中间 JSON 文件。db 选 "uf20"/"uft3", table 为 Constants::Cache 表名。 // srcMtime 为源目录最新修改时间(多源时相加), 写入 _meta.src_mtime 供缓存失效判断。 - void saveRowsFromJson(const QString& db, const QString& table, const QJsonObject& resultJson, + bool saveRowsFromJson(const QString& db, const QString& table, const QJsonObject& resultJson, double srcMtime = 0.0); // 统一处理入口:通过 IInterfaceScanner 策略多态处理,消除 UF2/UFT3 平行实现。 @@ -77,6 +84,8 @@ private: QString m_moduleGenerationCheck; bool m_forceFull = false; // 强制全量更新标志(兼容 UI;原版每次均全量解析,无增量缓存时不影响行为) + + std::atomic_bool m_cancelRequested{false}; // 协作式取消标志(原子,供后台线程读取) }; #endif // METADATAPROCESSOR_H \ No newline at end of file diff --git a/src/metadataupdate/uf2constants.cpp b/src/metadataupdate/uf2constants.cpp index 2c0df57..167f03b 100644 --- a/src/metadataupdate/uf2constants.cpp +++ b/src/metadataupdate/uf2constants.cpp @@ -1,5 +1,6 @@ #include "uf2constants.h" #include "utils/Constants.h" +#include "utils/logmanager.h" #include #include #include @@ -14,9 +15,14 @@ QMap> Uf2Constants::loadData(const QString& dirP QString filePath = dirPath + Constants::MetaPaths::Uf2Constants; QFile file(filePath); if (!file.exists()) { + // 文件缺失是路径配置错误/工程不完整的信号, 静默返回空表无任何日志可查 + LogManager::instance()->logWarning( + QString("UF2常量文件不存在, 返回空表: %1").arg(filePath)); return resultMap; } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + LogManager::instance()->logError( + QString("UF2常量文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } @@ -36,6 +42,10 @@ QMap> Uf2Constants::loadData(const QString& dirP resultMap[name] = entry; // key = constant_name } } + // XML 格式错误时部分常量静默缺失, 必须落日志 + if (reader.hasError()) { + LogManager::instance()->logError(QString("常量 XML 解析失败: %1 (%2)").arg(filePath, reader.errorString())); + } file.close(); return resultMap; } diff --git a/src/metadataupdate/uf2datatype.cpp b/src/metadataupdate/uf2datatype.cpp index dabfb02..628631a 100644 --- a/src/metadataupdate/uf2datatype.cpp +++ b/src/metadataupdate/uf2datatype.cpp @@ -53,6 +53,11 @@ QMap> Uf2DataType::loadData(const QString& dirPa cValue = xml.attributes().value("value").toString(); break; } + // 本元素内无 map 也必须停: 继续找会误读后续 language/database 的 map, + // 甚至一路吞到文档末尾, 导致剩余 userType 全部丢失 + if (token == QXmlStreamReader::EndElement && xml.name().toString() == "language") { + break; + } } } else if (childElementName == "database") { @@ -62,6 +67,9 @@ QMap> Uf2DataType::loadData(const QString& dirPa oValue = xml.attributes().value("value").toString(); break; } + if (token == QXmlStreamReader::EndElement && xml.name().toString() == "database") { + break; + } } } } @@ -80,6 +88,11 @@ QMap> Uf2DataType::loadData(const QString& dirPa } } + // XML 格式错误时部分类型静默缺失, 后续类型映射会查不到, 必须落日志 + if (xml.hasError()) { + LogManager::instance()->logError(QString("数据类型 XML 解析失败: %1 (%2)").arg(filePath, xml.errorString())); + } + file.close(); LogManager::instance()->log(QString("UF2数据类型加载完成,共 %1 条").arg(resultMap.size())); diff --git a/src/metadataupdate/uf2errnumber.cpp b/src/metadataupdate/uf2errnumber.cpp index 3d70c61..3bfaa3a 100644 --- a/src/metadataupdate/uf2errnumber.cpp +++ b/src/metadataupdate/uf2errnumber.cpp @@ -49,7 +49,14 @@ QMap> Uf2ErrNumber::loadData(const QString& dirP } } } - + + // 循环条件含 hasError: XML 中途损坏时静默截断会导致错误号缺失且无任何排查线索 + if (xml.hasError()) { + LogManager::instance()->logError( + QString("UF2错误号 XML 解析中断(文件损坏?): %1 行 %2 - %3") + .arg(filePath).arg(xml.lineNumber()).arg(xml.errorString())); + } + file.close(); LogManager::instance()->log(QString("UF2错误号加载完成,共 %1 条").arg(resultMap.size())); diff --git a/src/metadataupdate/uf2interface.cpp b/src/metadataupdate/uf2interface.cpp index c210d34..6d01586 100644 --- a/src/metadataupdate/uf2interface.cpp +++ b/src/metadataupdate/uf2interface.cpp @@ -9,6 +9,25 @@ #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) { } @@ -113,6 +132,11 @@ InterfaceInfo Uf2Interface::loadInterface(const QString& filePath) } } + // XML 格式错误时循环静默截断, 接口字段/代码不完整, 必须落日志 + if (xml.hasError()) { + LogManager::instance()->logError(QString("接口 XML 解析失败: %1 (%2)").arg(sFilePath, xml.errorString())); + } + file.close(); return result; } @@ -185,6 +209,7 @@ ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) std::function scanModuleXmlRecursive; scanModuleXmlRecursive = [&](const QString& path) { if (path.isEmpty()) return; + if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 QDir dir(path); QStringList filters; filters << "module.xml"; @@ -210,7 +235,8 @@ ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) moduleCodeMap[modName] = modEName; } - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 + QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); for (const QFileInfo& dirInfo : dirList) { scanModuleXmlRecursive(dirInfo.absoluteFilePath()); } @@ -230,6 +256,7 @@ ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) LogManager::instance()->log("路径为空,跳过"); return; } + if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 QDir dir(path); if (!dir.exists()) { LogManager::instance()->log(QString("目录不存在: %1").arg(path)); @@ -246,12 +273,7 @@ ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) } QString sFilePath = filePath; - int lastSlash = sFilePath.lastIndexOf('/'); - QString pathWithoutFile = sFilePath.left(lastSlash); - int secondLastSlash = pathWithoutFile.lastIndexOf('/'); - QString pathWithoutLastDir = pathWithoutFile.left(secondLastSlash); - int thirdLastSlash = pathWithoutLastDir.lastIndexOf('/'); - QString modName = pathWithoutLastDir.mid(thirdLastSlash + 1); + QString modName = extractModuleName(sFilePath); InterfaceInfo interfaceInfo = loadInterface(filePath); interfaceInfo.moudle = modName; @@ -259,6 +281,13 @@ ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) 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; @@ -272,7 +301,8 @@ ScanResult Uf2Interface::scanDir(const QString& dirPath, const ScanContext& ctx) } } - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + // 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); @@ -313,6 +343,7 @@ QMap Uf2Interface::buildModuleCodeMap(const QString& dirPath, std::function scanModuleXmlRecursive = [&](const QString& path) { if (path.isEmpty()) return; + if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 QDir dir(path); QStringList filters; filters << "module.xml"; @@ -339,12 +370,7 @@ QMap Uf2Interface::buildModuleCodeMap(const QString& dirPath, InterfaceInfo Uf2Interface::assemble(const QString& filePath, const ScanContext& ctx) { QString sFilePath = PathUtils::normPath(filePath); - int lastSlash = sFilePath.lastIndexOf('/'); - QString pathWithoutFile = sFilePath.left(lastSlash); - int secondLastSlash = pathWithoutFile.lastIndexOf('/'); - QString pathWithoutLastDir = pathWithoutFile.left(secondLastSlash); - int thirdLastSlash = pathWithoutLastDir.lastIndexOf('/'); - QString modName = pathWithoutLastDir.mid(thirdLastSlash + 1); + QString modName = extractModuleName(sFilePath); const QString& serverType = ctx.serverType; const QMap& moduleCodeMap = ctx.moduleCodeMap; @@ -364,6 +390,7 @@ QList Uf2Interface::collectFiles(const QString& dirPath, const ScanCo 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); diff --git a/src/metadataupdate/uf2menuandfunction.cpp b/src/metadataupdate/uf2menuandfunction.cpp index afc0cf9..24af2fa 100644 --- a/src/metadataupdate/uf2menuandfunction.cpp +++ b/src/metadataupdate/uf2menuandfunction.cpp @@ -1,5 +1,6 @@ #include "uf2menuandfunction.h" #include "utils/Constants.h" +#include "utils/logmanager.h" #include #include #include @@ -14,9 +15,15 @@ QMap> Uf2MenuAndFunction::loadData(const QString QString filePath = dirPath + Constants::MetaPaths::Uf2MenuFunc; QFile file(filePath); if (!file.exists()) { + // 文件缺失是路径配置错误/工程不完整的信号, 静默返回空表会让功能查询 + // 空白且无任何日志可查, 必须落日志 + LogManager::instance()->logWarning( + QString("UF2菜单功能宏文件不存在, 返回空表: %1").arg(filePath)); return resultMap; } if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + LogManager::instance()->logError( + QString("UF2菜单功能宏文件无法打开(被占用/权限不足?): %1").arg(filePath)); return resultMap; } @@ -114,6 +121,13 @@ QMap> Uf2MenuAndFunction::loadData(const QString while (inItem) { flush(); } + // XML 中途损坏时 reader.hasError() 置位, 菜单/功能宏表会静默缺失, 必须落日志 + if (reader.hasError()) { + LogManager::instance()->logError( + QString("UF2菜单功能宏 XML 解析中断(文件损坏?): %1 行 %2 - %3") + .arg(filePath).arg(reader.lineNumber()).arg(reader.errorString())); + } file.close(); + LogManager::instance()->log(QString("UF2菜单功能宏加载完成,共 %1 条").arg(resultMap.size())); return resultMap; } diff --git a/src/metadataupdate/uf2module.cpp b/src/metadataupdate/uf2module.cpp index ad76adc..25270f0 100644 --- a/src/metadataupdate/uf2module.cpp +++ b/src/metadataupdate/uf2module.cpp @@ -26,6 +26,9 @@ void Uf2Module::scanDir(const QString& dirPath) } QFile file(info.filePath()); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + // 打不开多为权限/占用问题, 静默跳过会让该模块缺失且无日志可查 + LogManager::instance()->logWarning( + QString("module.xml 无法打开, 已跳过: %1").arg(info.filePath())); continue; } QXmlStreamReader reader(&file); @@ -46,6 +49,9 @@ void Uf2Module::scanDir(const QString& dirPath) m_resultMap[ename] = entry; // key = ename } } + if (reader.hasError()) { + LogManager::instance()->logError(QString("模块 XML 解析失败: %1 (%2)").arg(info.filePath(), reader.errorString())); + } file.close(); } } diff --git a/src/metadataupdate/uf2stdfields.cpp b/src/metadataupdate/uf2stdfields.cpp index d0c1b1f..56302d2 100644 --- a/src/metadataupdate/uf2stdfields.cpp +++ b/src/metadataupdate/uf2stdfields.cpp @@ -42,7 +42,8 @@ QMap> Uf2StdFields::loadData( QString sCname = attrs.value("cname").toString(); QString sType = attrs.value("type").toString(); QString sDesc = attrs.value("desc").toString(); - sDesc.replace("'", "\""); + // 原样保留: 落库走 prepare+bindValue 参数绑定无需转义, + // 原 replace('->") 会把含撇号描述永久改写失真 QString sDictId = attrs.value("dict").toString(); QString sCtype = ""; @@ -66,7 +67,14 @@ QMap> Uf2StdFields::loadData( } } } - + + // 循环条件含 hasError: XML 中途损坏时静默截断会导致标准字段缺失且无任何排查线索 + if (xml.hasError()) { + LogManager::instance()->logError( + QString("UF2标准字段 XML 解析中断(文件损坏?): %1 行 %2 - %3") + .arg(filePath).arg(xml.lineNumber()).arg(xml.errorString())); + } + file.close(); LogManager::instance()->log(QString("UF2标准字段加载完成,共 %1 条").arg(resultMap.size())); diff --git a/src/metadataupdate/uf2table.cpp b/src/metadataupdate/uf2table.cpp index a04f699..8f0c52f 100644 --- a/src/metadataupdate/uf2table.cpp +++ b/src/metadataupdate/uf2table.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -40,16 +41,26 @@ void Uf2Table::scanDir(const QString& dirPath) if (!dbDir.exists()) { return; } - QFileInfoList entries = dbDir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); + // NoSymLinks: 目录符号链接(junction)可能成环, 递归跟进会死循环/重复扫描 + QFileInfoList entries = dbDir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot | QDir::NoSymLinks); for (const QFileInfo& info : entries) { if (info.isDir()) { scanDir(info.filePath()); } else if (info.fileName().endsWith(".table_design", Qt::CaseInsensitive) && info.fileName().compare("module.xml", Qt::CaseInsensitive) != 0) { - QString tableCName = info.baseName(); // 文件名去掉扩展名 + QString tableCName = info.completeBaseName(); // 文件名去掉扩展名(多点文件名用首点前会截断中间内容) QJsonObject obj = loadTable(info.filePath(), tableCName); QString tableName = obj.value("name").toString(); - if (!tableName.isEmpty()) { + if (tableName.isEmpty()) { + LogManager::instance()->logWarning( + QString("表结构缺少 tableName 属性, 已跳过: %1").arg(info.filePath())); + } else { + if (m_resultMap.contains(tableName)) { + LogManager::instance()->logWarning( + QString("发现重名表 %1: %2 将覆盖 %3") + .arg(tableName, info.filePath(), + m_resultMap.value(tableName).value("cname").toString())); + } m_resultMap[tableName] = obj; } } @@ -61,6 +72,7 @@ QJsonObject Uf2Table::loadTable(const QString& filePath, const QString& tableCNa QJsonObject result; QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + LogManager::instance()->logWarning(QString("无法打开表结构文件: %1").arg(filePath)); return result; } @@ -103,6 +115,10 @@ QJsonObject Uf2Table::loadTable(const QString& filePath, const QString& tableCNa } } } + // XML 格式错误时部分字段/索引静默缺失, 表结构信息不完整, 必须落日志 + if (reader.hasError()) { + LogManager::instance()->logError(QString("表结构 XML 解析失败: %1 (%2)").arg(filePath, reader.errorString())); + } file.close(); result["name"] = tableName; @@ -122,7 +138,31 @@ QMap Uf2Table::loadData(const QString& dirPath, const QStr scanDir(dbDir.path()); } if (!settDirPath.isEmpty() && QDir::isAbsolutePath(settDirPath)) { - scanDir(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; + } + 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())); + } + m_resultMap[tableName] = obj; + } + } } return m_resultMap; } diff --git a/src/metadataupdate/uft3datatype.cpp b/src/metadataupdate/uft3datatype.cpp index 22e824b..c0a35d3 100644 --- a/src/metadataupdate/uft3datatype.cpp +++ b/src/metadataupdate/uft3datatype.cpp @@ -65,7 +65,14 @@ QMap> Uft3DataType::loadData(const QString& dirP } } } - + + // 循环条件含 hasError: XML 中途损坏时静默截断会导致数据类型表缺失且无任何排查线索 + if (xml.hasError()) { + LogManager::instance()->logError( + QString("UFT3数据类型 XML 解析中断(文件损坏?): %1 行 %2 - %3") + .arg(filePath).arg(xml.lineNumber()).arg(xml.errorString())); + } + file.close(); LogManager::instance()->log(QString("UFT3数据类型加载完成,共 %1 条").arg(resultMap.size())); diff --git a/src/metadataupdate/uft3interface.cpp b/src/metadataupdate/uft3interface.cpp index 1f8e056..31b0b2d 100644 --- a/src/metadataupdate/uft3interface.cpp +++ b/src/metadataupdate/uft3interface.cpp @@ -6,15 +6,22 @@ #include #include #include -#include #include namespace { - // 从接口文件首行的属性串中提取 key="value" 中的 value(已 trimmed) + // 从接口文件首行的属性串中提取 key="value" 中的 value(已 trimmed)。 + // 需词边界保护: 直接 indexOf("id=\"") 会命中 objectId="..." 里的 id 子串, 取到错误属性值 QString extractAttr(const QString& line, const QString& attrName) { const QString token = attrName + "=\""; int pos = line.indexOf(token); + while (pos > 0) { + const QChar prev = line.at(pos - 1); + if (!prev.isLetterOrNumber() && prev != QLatin1Char('_')) { + break; + } + pos = line.indexOf(token, pos + 1); + } if (pos == -1) return QString(); pos += token.length(); int endPos = line.indexOf('"', pos); @@ -46,9 +53,13 @@ InterfaceInfo Uft3Interface::loadInterface(const QString& filePath) return result; } - QTextStream in(&file); - QString line = in.readLine(); - line = in.readLine(); + // 头两行是含中文的属性注释行: QTextStream 默认按系统 locale 解码, + // 遇 UTF-8 文件会把 chineseName 等读成乱码。按 XML 声明选择解码方式: + // GB 系声明用本地编码(Windows 中文环境即 GBK), 其余按 XML 缺省 UTF-8 + const QList headLines = file.peek(1024).split('\n'); + const bool gbDeclared = headLines.value(0).toLower().contains("gb"); + const QString line = gbDeclared ? QString::fromLocal8Bit(headLines.value(1)) + : QString::fromUtf8(headLines.value(1)); if (!line.isEmpty()) { result.cname = extractAttr(line, "chineseName"); @@ -74,34 +85,47 @@ InterfaceInfo Uft3Interface::loadInterface(const QString& filePath) QXmlStreamAttributes attrs = xml.attributes(); FieldInfo field; field.name = attrs.value("id").toString(); - field.flag = attrs.value("flags").toString(); - field.desc = attrs.value("comments").toString(); - field.uuid = attrs.value("uuid").toString(); - result.inputFields[field.name] = field; + // id 缺失时字段在 QMap 中全部坍缩进空键互相覆盖, 跳过并告警 + if (field.name.isEmpty()) { + LogManager::instance()->logWarning(QString("接口 %1 的 %2 节点缺少 id 属性, 已跳过该字段").arg(sFilePath, elementName)); + } else { + field.flag = attrs.value("flags").toString(); + field.desc = attrs.value("comments").toString(); + field.uuid = attrs.value("uuid").toString(); + result.inputFields[field.name] = field; + } } else if (elementName == "outputParameters") { QXmlStreamAttributes attrs = xml.attributes(); FieldInfo field; field.name = attrs.value("id").toString(); - field.flag = attrs.value("flags").toString(); - field.desc = attrs.value("comments").toString(); - field.paramType = attrs.value("paramType").toString(); - field.uuid = attrs.value("uuid").toString(); - result.outputFields[field.name] = field; - - if (field.flag.contains("IO")) { - result.inputFields[field.name] = field; + if (field.name.isEmpty()) { + LogManager::instance()->logWarning(QString("接口 %1 的 %2 节点缺少 id 属性, 已跳过该字段").arg(sFilePath, elementName)); + } else { + field.flag = attrs.value("flags").toString(); + field.desc = attrs.value("comments").toString(); + field.paramType = attrs.value("paramType").toString(); + field.uuid = attrs.value("uuid").toString(); + result.outputFields[field.name] = field; + + if (field.flag.contains("IO")) { + result.inputFields[field.name] = field; + } } } else if (elementName == "internalParams") { QXmlStreamAttributes attrs = xml.attributes(); FieldInfo field; field.name = attrs.value("id").toString(); - field.type = attrs.value("type").toString(); - field.desc = attrs.value("name").toString(); - field.paramType = attrs.value("paramType").toString(); - field.uuid = attrs.value("uuid").toString(); - result.internalFields[field.name] = field; + if (field.name.isEmpty()) { + LogManager::instance()->logWarning(QString("接口 %1 的 %2 节点缺少 id 属性, 已跳过该字段").arg(sFilePath, elementName)); + } else { + field.type = attrs.value("type").toString(); + field.desc = attrs.value("name").toString(); + field.paramType = attrs.value("paramType").toString(); + field.uuid = attrs.value("uuid").toString(); + result.internalFields[field.name] = field; + } } else if (elementName == "code") { result.code = xml.readElementText(); @@ -109,12 +133,13 @@ InterfaceInfo Uft3Interface::loadInterface(const QString& filePath) } } - file.close(); - - if (!result.functionNo.isEmpty() && !FileDB::instance()->uft3RealFunctionList.contains(result.functionNo)) { - FileDB::instance()->uft3RealFunctionList.append(result.functionNo); + // XML 格式错误时循环静默截断, 接口字段/代码不完整, 必须落日志 + if (xml.hasError()) { + LogManager::instance()->logError(QString("接口 XML 解析失败: %1 (%2)").arg(sFilePath, xml.errorString())); } + file.close(); + return result; } @@ -132,10 +157,13 @@ bool Uft3Interface::shouldFilter(const QString& filePath, return true; } - int dotPos = relPath.indexOf('.'); + // 只取文件名自身的扩展名参与保留名单匹配: 原 relPath 首点截断会把含点目录名 + // (v2.1/ 2024.06/)或多点文件名(func.v2.uftfunction)的中间内容一并算进 suffix, + // 与 .uftfunction 等整串精确匹配失败后被静默过滤(无日志), 导致接口漏扫 + const int dotPos = fileName.lastIndexOf('.'); QString suffix; if (dotPos != -1) { - suffix = relPath.mid(dotPos); + suffix = fileName.mid(dotPos); } if (!containNameList.contains(suffix)) { @@ -158,6 +186,7 @@ void Uft3Interface::scanDirRecursive(const QString& dirPath, QMap& resultMap, QMap& transCodeMap) { + if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 QDir dir(dirPath); QStringList filters; filters << "*.*"; @@ -176,15 +205,26 @@ void Uft3Interface::scanDirRecursive(const QString& dirPath, InterfaceInfo interfaceInfo = loadInterface(filePath); interfaceInfo.moudle = modName; - if (isTransCode) { - transCodeMap[interfaceInfo.cname] = interfaceInfo; - continue; + // cname 为空(接口头属性行缺失/格式变化)时以英文名兜底, 再以文件名兜底, + // 避免多个解析失败的接口全部坍缩进空键互相覆盖只剩一个 + if (interfaceInfo.cname.isEmpty()) { + interfaceInfo.cname = interfaceInfo.eName.isEmpty() + ? fileInfo.completeBaseName() : interfaceInfo.eName; + LogManager::instance()->logWarning( + QString("接口 %1 缺少中文名称属性, 以 %2 作为标识").arg(filePath, interfaceInfo.cname)); } - resultMap[interfaceInfo.cname] = interfaceInfo; + QMap& target = isTransCode ? transCodeMap : resultMap; + if (target.contains(interfaceInfo.cname)) { + LogManager::instance()->logWarning( + QString("发现重名接口 %1: %2 将覆盖 %3") + .arg(interfaceInfo.cname, filePath, target.value(interfaceInfo.cname).path)); + } + target[interfaceInfo.cname] = interfaceInfo; } - QFileInfoList dirList = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + // 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); @@ -229,6 +269,7 @@ QList Uft3Interface::collectFiles(const QString& dirPath, const ScanC [&](const QString& path) { if (path.isEmpty()) return; + if (isCancelRequested()) return; // 协作式取消: 外部请求后快速结束递归 QDir dir(path); QFileInfoList fileList = dir.entryInfoList(QStringList() << "*.*", QDir::Files); diff --git a/src/metadataupdate/uft3stdfields.cpp b/src/metadataupdate/uft3stdfields.cpp index 96168e6..1ace82d 100644 --- a/src/metadataupdate/uft3stdfields.cpp +++ b/src/metadataupdate/uft3stdfields.cpp @@ -42,7 +42,8 @@ QMap> Uft3StdFields::loadData( QString sCname = attrs.value("chineseName").toString(); QString sType = attrs.value("dataType").toString(); QString sDesc = attrs.value("description").toString(); - sDesc.replace("'", "\""); + // 落库走 DataCache 的 QSqlQuery::prepare + bindValue 参数绑定, + // 无需对单引号做任何转义, 原 replace 会把含撇号的描述永久改写失真 QString sDictId = attrs.value("dictionaryType").toString(); QString sUftType = ""; @@ -64,6 +65,11 @@ QMap> Uft3StdFields::loadData( } } + // XML 格式错误时部分标准字段静默缺失, 必须落日志 + if (xml.hasError()) { + LogManager::instance()->logError(QString("标准字段 XML 解析失败: %1 (%2)").arg(filePath, xml.errorString())); + } + file.close(); LogManager::instance()->log(QString("UFT3标准字段加载完成,共 %1 条").arg(resultMap.size())); diff --git a/src/metadataupdate/uft3table.cpp b/src/metadataupdate/uft3table.cpp index 5614447..7553b0f 100644 --- a/src/metadataupdate/uft3table.cpp +++ b/src/metadataupdate/uft3table.cpp @@ -44,7 +44,8 @@ void Uft3Table::scanDirectory(const QString& dirPath, QMaplogError(QString("表结构 XML 解析失败: %1 (%2)").arg(sFilePath, xml.errorString())); + } + QString sUser = ""; if (spaceName == "HS_UFT_DATA") { sUser = "hs_uft"; diff --git a/src/pages/batchconvert/batchconvertpage.cpp b/src/pages/batchconvert/batchconvertpage.cpp index 57f4bf9..c6e5f70 100644 --- a/src/pages/batchconvert/batchconvertpage.cpp +++ b/src/pages/batchconvert/batchconvertpage.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ #include "ElaPushButton.h" #include "ElaProgressBar.h" #include "ElaText.h" +#include "ElaTheme.h" namespace { @@ -93,6 +95,11 @@ BatchConvertPage::BatchConvertPage(QWidget *parent) m_lastFileMtime = currentFileMtime(); setupConfigWatcher(); + + // 灰字进度标签跟随主题明暗切换(上轮修改实际未落盘, 此处补全) + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &BatchConvertPage::updateTipStyle); + updateTipStyle(); } BatchConvertPage::~BatchConvertPage() @@ -150,7 +157,6 @@ void BatchConvertPage::initUI() progressLayout->setSpacing(10); m_progressLabel = new ElaText("等待开始..."); - m_progressLabel->setStyleSheet("color: #666; font-size: 12px;"); m_progressLabel->setFixedHeight(40); m_progressLabel->setWordWrap(false); m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); @@ -232,9 +238,13 @@ void BatchConvertPage::updateTable() editBtn->setIconSize(QSize(20, 20)); editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;"); editBtn->setToolTip("修改"); - connect(editBtn, &QPushButton::clicked, this, [this, i]() { - onEditRow(i); - }); + // 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); } @@ -263,6 +273,16 @@ void BatchConvertPage::showEvent(QShowEvent *event) 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()) { @@ -310,6 +330,8 @@ void BatchConvertPage::onEditRow(int row) updateTable(); if (saveToCustJson(m_funcList)) { loadFromCustJson(); + } else { + QMessageBox::warning(this, "保存失败", "函数名已在内存中修改,但写入 cust.json 失败(文件被占用或权限不足?),重启后修改将丢失!"); } } @@ -376,6 +398,14 @@ void BatchConvertPage::onAddFunction() QString("成功添加 %1 个函数").arg(addedCount)); } + // 落盘: 新增函数持久化到 cust.json, 否则只写内存重启后丢失(与 onEditRow 保持一致) + if (addedCount > 0) { + if (saveToCustJson(m_funcList)) { + loadFromCustJson(); + } else { + QMessageBox::warning(this, "保存失败", "新增函数已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将丢失!"); + } + } updateTable(); } @@ -403,6 +433,8 @@ void BatchConvertPage::onRemoveFunction() updateTable(); if (saveToCustJson(m_funcList)) { loadFromCustJson(); + } else { + QMessageBox::warning(this, "保存失败", "删除操作已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将恢复原清单!"); } } @@ -420,10 +452,21 @@ void BatchConvertPage::onClearTable() 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) { QString jsonPath = Constants::custJson(); @@ -457,22 +500,42 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList) int bracketStart = dirMatch.capturedEnd() - 1; // 指向 '[' - // 括号计数找到匹配的 ']' + // 括号计数找到匹配的 ']'(必须跳过字符串字面量, 否则字符串内的 [ ] 会截断匹配改坏 JSON) int bracketCount = 1; int pos = bracketStart + 1; + bool inString = false; + bool escaped = false; while (pos < content.length() && bracketCount > 0) { QChar ch = content[pos]; - if (ch == '[') bracketCount++; - else if (ch == ']') bracketCount--; + if (inString) { + if (escaped) { escaped = false; } + else if (ch == '\\') { escaped = true; } + else if (ch == '"') { inString = false; } + } else { + if (ch == '"') { inString = true; } + else if (ch == '[') bracketCount++; + else if (ch == ']') bracketCount--; + } pos++; } + if (bracketCount != 0) { + LogManager::instance()->logWarning("全量转码 - 保存配置失败,dir 数组括号不匹配"); + return false; + } 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 \"" + funcList[i] + ".service_design\""; + newArray += "\n \"" + jsonEscape(funcList[i]) + ".service_design\""; } if (!funcList.isEmpty()) { newArray += "\n "; @@ -482,15 +545,30 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList) // 只替换 [bracketStart, bracketEnd) 区间,其余内容原样保留 content.replace(bracketStart, bracketEnd - bracketStart, newArray); - // 写回 - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + // 写回前校验替换结果仍是合法 JSON, 防止改坏的配置被原地写回无法恢复 + QJsonParseError parseErr; + QJsonDocument::fromJson(content.toUtf8(), &parseErr); + if (parseErr.error != QJsonParseError::NoError) { + LogManager::instance()->logWarning( + QString("全量转码 - 保存配置中止, 替换后 JSON 非法: %1").arg(parseErr.errorString())); + return false; + } + + // QSaveFile 原子写: 先写临时文件, commit 成功才替换目标文件, + // 避免写入中途崩溃/断电损坏配置 + QSaveFile saveFile(jsonPath); + if (!saveFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { LogManager::instance()->logWarning( QString("全量转码 - 保存配置失败,无法写入: %1").arg(jsonPath)); return false; } - file.write(content.toUtf8()); - file.close(); + saveFile.write(content.toUtf8()); + if (!saveFile.commit()) { + LogManager::instance()->logWarning( + QString("全量转码 - 保存配置失败,写入提交异常: %1").arg(jsonPath)); + return false; + } // 程序自身写入后更新 mtime,避免触发无谓的重载 m_lastFileMtime = currentFileMtime(); @@ -577,6 +655,14 @@ void BatchConvertPage::onConvertFinished() 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) { @@ -612,6 +698,46 @@ void BatchConvertPage::addFunction(const QString &funcName) } } +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(); diff --git a/src/pages/batchconvert/batchconvertpage.h b/src/pages/batchconvert/batchconvertpage.h index 0e81e4d..28e181b 100644 --- a/src/pages/batchconvert/batchconvertpage.h +++ b/src/pages/batchconvert/batchconvertpage.h @@ -7,6 +7,7 @@ #include #include #include +#include #include "ElaLineEdit.h" #include "ElaTableView.h" #include "ElaProgressBar.h" @@ -27,8 +28,13 @@ signals: 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); @@ -41,9 +47,11 @@ private slots: 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(); diff --git a/src/pages/codesearch/codesearchpage.cpp b/src/pages/codesearch/codesearchpage.cpp index 8d01429..6f89da4 100644 --- a/src/pages/codesearch/codesearchpage.cpp +++ b/src/pages/codesearch/codesearchpage.cpp @@ -19,9 +19,11 @@ #include #include #include +#include #include #include #include "ElaText.h" +#include "ElaTheme.h" #include "utils/datacache.h" #include "utils/logmanager.h" #include "utils/Constants.h" @@ -74,6 +76,12 @@ namespace { // 当前查询的函数/功能行高亮角色:强制绘制蓝色背景,突出查询节点 const int RowHighlightBlueRole = Qt::UserRole + 101; + // runQuery 后台任务的结果: 调用链构建在 worker 线程执行, 完成后回主线程渲染 + struct QueryResult { + CallChainAnalyzer::TreeNode downTree; + QList roots; + }; + class CallTreeDelegate : public QStyledItemDelegate { public: @@ -128,10 +136,18 @@ CodeSearchPage::CodeSearchPage(QWidget *parent) } m_pathByMode[ConvertMode] = convertPath; m_pathByMode[SearchMode] = searchPath; - // 将(可能为默认的)路径写回配置,确保空配置也会被落盘,下次直接命中 - cc.setValue(Constants::CodeSearch::ConvertPathKey, QDir::fromNativeSeparators(convertPath)); - cc.setValue(Constants::CodeSearch::SearchPathKey, QDir::fromNativeSeparators(searchPath)); - cc.sync(); + // 仅在键原本缺失/为空时写回默认路径: 避免每次启动都重写 ini(磁盘写+sync 同步阻塞) + if (convertPath.isEmpty() || searchPath.isEmpty()) { + if (convertPath.isEmpty()) { + cc.setValue(Constants::CodeSearch::ConvertPathKey, + QDir::fromNativeSeparators(m_pathByMode[ConvertMode])); + } + if (searchPath.isEmpty()) { + cc.setValue(Constants::CodeSearch::SearchPathKey, + QDir::fromNativeSeparators(m_pathByMode[SearchMode])); + } + cc.sync(); + } initUI(); } @@ -140,7 +156,19 @@ CallChainAnalyzer *CodeSearchPage::currentAnalyzer() const return m_analyzers[m_mode]; } -CodeSearchPage::~CodeSearchPage() = default; +CodeSearchPage::~CodeSearchPage() +{ + // 后台任务通过裸指针访问成员 m_analyzers, 页面析构前必须等待在途任务结束, + // 否则 QtConcurrent 线程访问悬垂指针导致退出阶段偶发崩溃。 + for (auto *w : m_activeWatchers) { + w->waitForFinished(); + } + // 若析构时有在途后台任务, 其 finished 回调不会再执行, 等待光标须在此恢复; + // 守卫检查: 栈空时 restoreOverrideCursor 会 qWarning + if (QApplication::overrideCursor()) { + QApplication::restoreOverrideCursor(); + } +} void CodeSearchPage::initUI() { @@ -306,7 +334,10 @@ void CodeSearchPage::initUI() // 底部静态提示(仿 UF20 功能查询页最下方提示) QHBoxLayout *tipLayout = new QHBoxLayout(); m_bottomTip = new QLabel("💡 双击左侧业务转码函数查看调用链路"); - m_bottomTip->setStyleSheet("color: #666; font-size: 13px;"); + // 主题联动: 灰字颜色随明暗模式切换, 原硬编码 #666 在暗色主题下几乎不可读 + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &CodeSearchPage::updateTipStyle); + updateTipStyle(); tipLayout->addWidget(m_bottomTip); tipLayout->addStretch(); mainLayout->addLayout(tipLayout); @@ -315,6 +346,13 @@ void CodeSearchPage::initUI() applyMode(); } +void CodeSearchPage::updateTipStyle() +{ + const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); + const QString textColor = ElaThemeColor(mode, BasicText).name(); + m_bottomTip->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor)); +} + void CodeSearchPage::applyMode() { const bool convert = (m_mode == ConvertMode); @@ -402,6 +440,11 @@ void CodeSearchPage::onRefreshIndex() if (m_indexing) { return; } + // 后台任务(在途查询/静默索引)与本次构建共用同一 analyzer, 并发访问构成数据竞争 + if (!m_activeWatchers.isEmpty()) { + QMessageBox::information(this, "提示", "当前有任务正在执行, 请稍候再试"); + return; + } m_pathByMode[m_mode] = path; @@ -411,20 +454,23 @@ void CodeSearchPage::onRefreshIndex() CallChainAnalyzer *ca = currentAnalyzer(); ca->setRootPath(path); + // 记录发起时的模式与 analyzer: 构建期间模式可能被切换(静默链路不挡 UI), + // 回调必须写回发起模式的条目, 避免跨模式串写 + const int mode = m_mode; // 后台线程构建,期间显示进度框,避免界面卡死 m_indexing = true; runIndexBuild(ca, "正在刷新代码索引,请稍候...", - [this, path](bool ok, const QString &message) { + [this, path, mode, ca](bool ok, const QString &message) { m_indexing = false; if (ok) { - currentAnalyzer()->saveToCache(); // 写入缓存,后续直接读取 - m_indexedPathByMode[m_mode] = path; - m_userReSelectedByMode[m_mode] = false; + ca->saveToCache(); // 写入缓存,后续直接读取 + m_indexedPathByMode[mode] = path; + m_userReSelectedByMode[mode] = false; LogManager::instance()->log(QString("[代码检索] %1").arg(message)); QMessageBox::information(this, "刷新成功", message.isEmpty() ? QString("索引刷新成功") : message); } else { - m_indexedPathByMode[m_mode] = path; // 避免重复自动重试 + m_indexedPathByMode[mode] = path; // 避免重复自动重试 QMessageBox::warning(this, "刷新失败", message.isEmpty() ? QString("索引刷新失败") : message); } @@ -444,7 +490,8 @@ void CodeSearchPage::savePathsToConfig() void CodeSearchPage::refreshIndexSilent() { - if (m_indexing) { + if (m_indexing || !m_activeWatchers.isEmpty()) { + // 有构建/查询在途时静默跳过: 与在途任务共用同一 analyzer 会构成数据竞争 return; } // 收集已配置路径的模式,后台线程依次异步刷新,避免阻塞转换完成后的界面 @@ -506,10 +553,13 @@ void CodeSearchPage::onFunctionDoubleClicked(const QModelIndex &index) runQuery(displayName); } -void CodeSearchPage::populateTree(const QList &rootNodes) +void CodeSearchPage::populateTree(const QList &rootNodes, int mode) { - QStandardItemModel *model = m_treeModel[m_mode]; - ElaTreeView *tree = m_callTree[m_mode]; + // mode<0 时用当前模式(兼容未传 mode 的调用), 否则用调用方指定的发起时模式, + // 避免后台回调期间模式被切换导致结果串写另一模式的树 + const int m = (mode >= 0) ? mode : m_mode; + QStandardItemModel *model = m_treeModel[m]; + ElaTreeView *tree = m_callTree[m]; model->removeRows(0, model->rowCount()); @@ -697,29 +747,46 @@ QString CodeSearchPage::resolveChineseName(const QString &displayName) const void CodeSearchPage::autoBuildIndexIfNeeded() { - const QString path = m_pathByMode[m_mode].trimmed(); + // 记录发起时的模式: 自动构建静默无弹窗, 不挡 UI, 构建期间用户可能切换模式, + // 回调必须写回发起模式的条目, 避免跨模式串写 + const int mode = m_mode; + const QString path = m_pathByMode[mode].trimmed(); + // 该路径此前已尝试过自动构建(无论成败)则不再自动重试: + // 成功场景由 isIndexed() 兜底;失败场景若不读此登记,会导致每次激活页面都重试构建+弹框 + if (m_indexedPathByMode[mode] == path) return; // 仅当:路径有效、尚未索引、非用户手动改过路径、且未正在构建时,才默认自动加载 - if (path.isEmpty() || m_userReSelectedByMode[m_mode] + if (path.isEmpty() || m_userReSelectedByMode[mode] || currentAnalyzer()->isIndexed() || m_indexing) { return; } + // 在途任务(查询/静默刷新)与自动构建共用同一 analyzer, 并发访问构成数据竞争, 静默跳过 + if (!m_activeWatchers.isEmpty()) { + return; + } CallChainAnalyzer *ca = currentAnalyzer(); ca->setRootPath(path); // 统一的“全量构建/重建”流程:后台线程扫描目录,避免界面卡死; // 构建完成后写入缓存,供下次直接读取。 - auto fullBuild = [this, path]() { + auto fullBuild = [this, path, mode, ca]() { + // 缓存加载回退链的中间窗口可能已有任务在途, 发起前再防一次并发; + // 跳过时同样标记已尝试, 避免每次激活页面都重试 + if (!m_activeWatchers.isEmpty()) { + m_indexedPathByMode[mode] = path; + LogManager::instance()->logWarning("[代码检索-自动] 有任务在途, 已跳过自动构建"); + return; + } m_indexing = true; - runIndexBuild(currentAnalyzer(), "正在构建代码索引,请稍候...", - [this, path](bool ok, const QString &message) { + runIndexBuild(ca, "正在构建代码索引,请稍候...", + [this, path, mode, ca](bool ok, const QString &message) { m_indexing = false; if (ok) { - currentAnalyzer()->saveToCache(); - m_indexedPathByMode[m_mode] = path; + ca->saveToCache(); + m_indexedPathByMode[mode] = path; LogManager::instance()->log(QString("[代码检索-自动] %1").arg(message)); } else { - m_indexedPathByMode[m_mode] = path; // 标记已尝试,避免每次显示重复重试 + m_indexedPathByMode[mode] = path; // 标记已尝试,避免每次显示重复重试 } }); }; @@ -729,14 +796,14 @@ void CodeSearchPage::autoBuildIndexIfNeeded() if (ca->cacheQuickValid(path)) { m_indexing = true; runCacheLoad(ca, "正在加载索引缓存,请稍候...", /*skipFingerprint=*/true, - [this, path, fullBuild](bool ok, const QString &message) { + [this, path, mode, fullBuild](bool ok, const QString &message) { m_indexing = false; if (ok) { - m_indexedPathByMode[m_mode] = path; + m_indexedPathByMode[mode] = path; LogManager::instance()->log(QString("[代码检索-缓存] %1").arg(message)); } else { // 粗判命中但读缓存失败(极少见,如缓存损坏):回退全量构建 - m_indexedPathByMode[m_mode] = path; // 标记已尝试 + m_indexedPathByMode[mode] = path; // 标记已尝试 fullBuild(); } }); @@ -765,7 +832,17 @@ void CodeSearchPage::runAsync(CallChainAnalyzer *ca, const QString &title, using BuildResult = QPair; auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [=]() { + + // 在后台线程执行实际任务,避免阻塞 UI 线程 + auto future = QtConcurrent::run([=]() -> BuildResult { + return task(ca); + }); + // 登记任务句柄, 供析构函数等待在途任务完成 + m_activeWatchers.append(watcher); + + connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, dlg, onDone]() { + m_activeWatchers.removeOne(watcher); const BuildResult r = watcher->result(); watcher->deleteLater(); if (dlg) { @@ -777,10 +854,6 @@ void CodeSearchPage::runAsync(CallChainAnalyzer *ca, const QString &title, } }); - // 在后台线程执行实际任务,避免阻塞 UI 线程 - auto future = QtConcurrent::run([=]() -> BuildResult { - return task(ca); - }); watcher->setFuture(future); } @@ -840,6 +913,15 @@ void CodeSearchPage::runQuery(const QString &displayName) return; } + // 后台任务(索引构建/在途查询)与本次查询共用同一 analyzer, 并发访问构成数据竞争 + if (!m_activeWatchers.isEmpty()) { + QMessageBox::information(this, "提示", "当前有任务正在执行, 请稍候再试"); + return; + } + + // 记录发起时的模式: 后台查询运行中模式可能被切换(onPivotClicked 不挡 UI), + // 回调必须写回发起模式的条目, 避免跨模式串写结果树与导出基准路径 + const int mode = m_mode; const QString chineseName = resolveChineseName(displayName); // 记录当前查询的函数/功能名,供调用链树中对应行蓝色高亮。 @@ -854,37 +936,51 @@ void CodeSearchPage::runQuery(const QString &displayName) : QString("%1-%2").arg(hi->objectId, hi->chineseName); } + // buildCallTree/buildCallerForest 在大索引上可能耗时数秒, 放到后台线程执行, + // 完成后回主线程刷新树(树控件只能主线程操作) QApplication::setOverrideCursor(Qt::WaitCursor); - // buildCallTree 内部使用多策略查找,如果未找到根节点会标记为 "notfound" - CallChainAnalyzer::TreeNode downTree = currentAnalyzer()->buildCallTree(chineseName); - - if (downTree.state != "ok") { + QFutureWatcher *watcher = new QFutureWatcher(this); + auto *analyzer = currentAnalyzer(); + auto future = QtConcurrent::run([analyzer, chineseName]() -> QueryResult { + QueryResult r; + // buildCallTree 内部使用多策略查找,如果未找到根节点会标记为 "notfound" + r.downTree = analyzer->buildCallTree(chineseName); + if (r.downTree.state == "ok") { + // 往前查:构造上游调用方森林(每条链以 downTree 收尾),无上游时退化为单根 downTree + r.roots = analyzer->buildCallerForest(chineseName, r.downTree); + } + return r; + }); + m_activeWatchers.append(watcher); + connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, displayName]() { + m_activeWatchers.removeOne(watcher); QApplication::restoreOverrideCursor(); - QMessageBox::information(this, "未找到", - QString("未在索引中找到函数:%1\n请确认代码检索路径正确并已刷新索引。").arg(displayName)); - return; - } + const QueryResult r = watcher->result(); + if (r.downTree.state != "ok") { + QMessageBox::information(this, "未找到", + QString("未在索引中找到函数:%1\n请确认代码检索路径正确并已刷新索引。").arg(displayName)); + return; + } - // 往前查:构造上游调用方森林(每条链以 downTree 收尾),无上游时退化为单根 downTree - QList roots = - currentAnalyzer()->buildCallerForest(chineseName, downTree); + const QList roots = r.roots; + populateTree(roots); + m_callTree[m_mode]->expandToDepth(2); - populateTree(roots); - QApplication::restoreOverrideCursor(); - - m_callTree[m_mode]->expandToDepth(2); - - // 收集调用链路文件(排除"原代码已支持"),并记录导出基准路径与查询名 - QSet collected; - for (const CallChainAnalyzer::TreeNode &r : roots) { - collectChainFiles(r, collected); - } - m_currentChainRoots = roots; - m_currentChainFilePaths = collected.values(); - m_searchRootPath = m_pathEdit->text().trimmed(); - m_currentQueryName = displayName; - m_importBtn->setEnabled(!m_currentChainFilePaths.isEmpty()); - m_exportChainBtn->setEnabled(!m_currentChainFilePaths.isEmpty()); + // 收集调用链路文件(排除"原代码已支持"),并记录导出基准路径与查询名 + QSet collected; + for (const CallChainAnalyzer::TreeNode &root : roots) { + collectChainFiles(root, collected); + } + m_currentChainRoots = roots; + m_currentChainFilePaths = collected.values(); + m_searchRootPath = m_pathEdit->text().trimmed(); + m_currentQueryName = displayName; + m_importBtn->setEnabled(!m_currentChainFilePaths.isEmpty()); + m_exportChainBtn->setEnabled(!m_currentChainFilePaths.isEmpty()); + watcher->deleteLater(); + }); + watcher->setFuture(future); } void CodeSearchPage::onExpandAll() @@ -981,24 +1077,35 @@ void CodeSearchPage::onImportChainFiles() } } - // 生成调用链路文本文件(遍历整片森林,含往前查的上游链) + // 生成调用链路文本文件(遍历整片森林,含往前查的上游链)。 + // QSaveFile 原子写; 原实现打开失败静默且中断会把半截文件替换到目标位置 QStringList chainLines; 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)) { + 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(); } QString msg = QString("已将 %1 个调用文件导出到:\n%2").arg(copied).arg(QDir::toNativeSeparators(queryDir)); if (!failed.isEmpty()) { msg += QString("\n\n%1 个文件导出失败:\n%2").arg(failed.size()).arg(failed.join("\n")); } + if (!chainTxtOk) { + // 打开或提交失败必须显式告知: 用户依赖 _call_chain.txt 排查调用链路 + QMessageBox::critical(this, "链路文本写入失败", + QString("调用链路文本写入失败(磁盘空间不足/目录只读?):\n%1") + .arg(QDir::toNativeSeparators(txtPath))); + LogManager::instance()->logError("调用链路文本写入失败: " + txtPath); + } QMessageBox::information(this, "导出完成", msg); } diff --git a/src/pages/codesearch/codesearchpage.h b/src/pages/codesearch/codesearchpage.h index 5870f7b..1da510a 100644 --- a/src/pages/codesearch/codesearchpage.h +++ b/src/pages/codesearch/codesearchpage.h @@ -2,6 +2,7 @@ #define CODESEARCHPAGE_H #include +#include #include #include #include @@ -72,11 +73,13 @@ private slots: void onQueryByInput(); void savePathsToConfig(); void onPivotClicked(int index); + // 底部提示跟随主题明暗切换 + void updateTipStyle(); private: void initUI(); void loadFunctionList(); - void populateTree(const QList &rootNodes); + void populateTree(const QList &rootNodes, int mode = -1); void autoResizeColumns(ElaTreeView *tree); void addTreeItem(QStandardItem *parentItem, const CallChainAnalyzer::TreeNode &node); QString resolveChineseName(const QString &displayName) const; @@ -132,6 +135,9 @@ private: QString m_currentQueryName; // 当前查询的函数名称(导出目录名) QString m_highlightName; // 当前查询的函数/功能名(调用链树中对应行蓝色高亮) bool m_indexing = false; // 后台索引构建进行中,防止重复触发 + // 在途后台任务的 watcher: 任务通过裸指针访问 m_analyzers, 页面析构时必须等待完成防止 use-after-free。 + // 存 watcher 指针而非 QFuture 值: QFuture 无 operator== 无法从列表移除, 指针比较即可 + QList m_activeWatchers; }; #endif // CODESEARCHPAGE_H diff --git a/src/pages/datamapping/functionmappingpage.cpp b/src/pages/datamapping/functionmappingpage.cpp index 65ab232..2ed2bc5 100644 --- a/src/pages/datamapping/functionmappingpage.cpp +++ b/src/pages/datamapping/functionmappingpage.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,10 @@ #include "ElaPushButton.h" #include "ElaLineEdit.h" #include "ElaText.h" +#include "ElaTheme.h" +#include +#include +#include // 自定义代理模型:支持两列同时过滤 class FuncFilterProxyModel : public QSortFilterProxyModel @@ -109,23 +114,74 @@ void FunctionMappingPage::initUI() mainLayout->addLayout(btnLayout); QHBoxLayout *tipLayout = new QHBoxLayout; - QLabel *tipLabel = new QLabel("💡 双击表格修改数据"); - tipLabel->setStyleSheet("color: #666; font-size: 13px;"); - tipLayout->addWidget(tipLabel); + 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()); - QJsonObject custJson = readCustJson(); if (custJson.contains("functionReplace")) { QJsonObject dataObj = custJson["functionReplace"].toObject(); QStringList keys = dataObj.keys(); @@ -138,11 +194,11 @@ void FunctionMappingPage::loadData() } m_isLoading = false; + m_dirty = false; // 加载完成后与磁盘内容一致,清除脏标志 - // 设置文件监控 - QString jsonPath = getCustJsonPath(); - if (!m_fileWatcher->files().contains(jsonPath)) - m_fileWatcher->addPath(jsonPath); + // 设置文件+目录双监控并记录 mtime 基准, 供防抖回调过滤自身写入回环 + refreshWatchPaths(); + m_lastFileMtime = currentFileMtime(); } QString FunctionMappingPage::getCustJsonPath() const @@ -150,8 +206,9 @@ QString FunctionMappingPage::getCustJsonPath() const return Constants::custJson(); } -QJsonObject FunctionMappingPage::readCustJson() +QJsonObject FunctionMappingPage::readCustJson(bool *ok) { + if (ok) *ok = false; QString jsonPath = getCustJsonPath(); QFile file(jsonPath); if (!file.open(QIODevice::ReadOnly)) { @@ -163,22 +220,47 @@ QJsonObject FunctionMappingPage::readCustJson() QJsonParseError error; QJsonDocument doc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { - LogManager::instance()->logError("cust.json 解析失败: " + error.errorString()); + 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 的顺序 +// 从原始 JSON 文本中提取顶层 key 的顺序: +// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键。 +// 旧正则 "\w+" 无法匹配中文/特殊字符键, 且会误捕嵌套键, 导致保存时顶层字段丢失 static QStringList extractTopLevelKeyOrder(const QString &rawJson) { QStringList keys; - QRegularExpression re("\"(\\w+)\"\\s*:"); - auto it = re.globalMatch(rawJson); - while (it.hasNext()) { - QString key = it.next().captured(1); - if (!keys.contains(key)) - keys.append(key); + 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; } @@ -249,8 +331,10 @@ bool FunctionMappingPage::writeCustJson(const QJsonObject &json, const QStringLi const QString &orderedKey, const QList> &orderedData) { QString jsonPath = getCustJsonPath(); - QFile file(jsonPath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + // 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; } @@ -270,7 +354,11 @@ bool FunctionMappingPage::writeCustJson(const QJsonObject &json, const QStringLi } } out << "\n}\n"; - file.close(); + out.flush(); + if (!file.commit()) { + LogManager::instance()->logError("cust.json 提交失败(磁盘空间/权限/占用?): " + jsonPath); + return false; + } return true; } @@ -368,11 +456,13 @@ void FunctionMappingPage::onDeleteRow() 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); } @@ -380,11 +470,16 @@ 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; } @@ -394,6 +489,8 @@ void FunctionMappingPage::onSave() 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; } @@ -418,6 +515,8 @@ void FunctionMappingPage::onSave() 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 { @@ -425,20 +524,60 @@ void FunctionMappingPage::onSave() m_isSaving = false; } - // 延迟重置保存标记,让文件监控忽略本次写入事件 - QTimer::singleShot(2000, this, [this]() { + // 延迟重置保存标记,让文件监控忽略本次写入事件(比对代数, 见 onSave 开头) + QTimer::singleShot(2000, this, [this, seq]() { + if (m_saveSeq != seq) return; // 窗口期内又有新保存, 本计时器失效 m_isSaving = false; // 重新添加监控路径 - QString path = getCustJsonPath(); - if (!m_fileWatcher->files().contains(path)) - m_fileWatcher->addPath(path); + 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(); } diff --git a/src/pages/datamapping/functionmappingpage.h b/src/pages/datamapping/functionmappingpage.h index c4e5b00..348b503 100644 --- a/src/pages/datamapping/functionmappingpage.h +++ b/src/pages/datamapping/functionmappingpage.h @@ -7,9 +7,12 @@ #include #include #include +#include #include "ElaTableView.h" class ElaLineEdit; +class QLabel; +class QTimer; class FunctionMappingPage : public QWidget { @@ -25,22 +28,34 @@ private slots: 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(); + 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/tablemappingpage.cpp b/src/pages/datamapping/tablemappingpage.cpp index 7ccff49..3a3e397 100644 --- a/src/pages/datamapping/tablemappingpage.cpp +++ b/src/pages/datamapping/tablemappingpage.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,12 +16,16 @@ #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 @@ -109,15 +114,29 @@ void TableMappingPage::initUI() mainLayout->addLayout(btnLayout); QHBoxLayout *tipLayout = new QHBoxLayout; - QLabel *tipLabel = new QLabel("💡 双击表格修改数据"); - tipLabel->setStyleSheet("color: #666; font-size: 13px;"); - tipLayout->addWidget(tipLabel); + m_tipLabel = new QLabel("💡 双击表格修改数据"); + m_tipLabel->setStyleSheet("color: #666; font-size: 13px;"); + tipLayout->addWidget(m_tipLabel); tipLayout->addStretch(); mainLayout->addLayout(tipLayout); - // 文件监控:检测 cust.json 外部修改后自动刷新 + // 文件监控:检测 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() @@ -138,11 +157,31 @@ void TableMappingPage::loadData() } m_isLoading = false; + m_dirty = false; // 加载完成后与磁盘内容一致,清除脏标志 - // 设置文件监控 - QString jsonPath = getCustJsonPath(); - if (!m_fileWatcher->files().contains(jsonPath)) + // 设置文件监控(文件+目录双监控)并记录 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 @@ -169,16 +208,39 @@ QJsonObject TableMappingPage::readCustJson() return doc.object(); } -// 从原始 JSON 文本中提取顶层 key 的顺序 +// 从原始 JSON 文本中提取顶层 key 的顺序: +// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键。 +// 旧正则 "\w+" 无法匹配中文/特殊字符键, 且会误捕嵌套键, 导致保存时顶层字段丢失 static QStringList extractTopLevelKeyOrder(const QString &rawJson) { QStringList keys; - QRegularExpression re("\"(\\w+)\"\\s*:"); - auto it = re.globalMatch(rawJson); - while (it.hasNext()) { - QString key = it.next().captured(1); - if (!keys.contains(key)) - keys.append(key); + 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; } @@ -249,7 +311,9 @@ bool TableMappingPage::writeCustJson(const QJsonObject &json, const QStringList const QString &orderedKey, const QList> &orderedData) { QString jsonPath = getCustJsonPath(); - QFile file(jsonPath); + // QSaveFile 原子写: 先写临时文件成功后再 commit 替换目标, + // 避免写一半崩溃/断电留下截断损坏的 cust.json(QFile+Truncate 会先清空原文件) + QSaveFile file(jsonPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { LogManager::instance()->logError("无法写入 cust.json: " + jsonPath); return false; @@ -270,7 +334,12 @@ bool TableMappingPage::writeCustJson(const QJsonObject &json, const QStringList } } out << "\n}\n"; - file.close(); + out.flush(); + if (!file.commit()) { + LogManager::instance()->logError( + QString("cust.json 提交失败(磁盘空间/权限/占用?): %1").arg(jsonPath)); + return false; + } return true; } @@ -368,11 +437,13 @@ void TableMappingPage::onDeleteRow() 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); } @@ -385,6 +456,8 @@ void TableMappingPage::onSave() QString jsonPath = getCustJsonPath(); QFile file(jsonPath); if (!file.open(QIODevice::ReadOnly)) { + // 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制 + m_isSaving = false; QMessageBox::critical(this, "错误", "无法读取 cust.json!"); return; } @@ -394,6 +467,8 @@ void TableMappingPage::onSave() 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; } @@ -402,7 +477,9 @@ void TableMappingPage::onSave() // 提取原始 key 顺序 QStringList keyOrder = extractTopLevelKeyOrder(rawJson); - // 构建有序的表替换数据 + // 构建有序的表替换数据。 + // 重复 key 校验: 就地编辑第一列可能改成已有表名(JSON 对象同键去重会静默丢失其中一行), + // onAddRow 只覆盖"新增"入口, 编辑入口必须在此兜底 QJsonObject tableCompareObj; QList> tableData; for (int row = 0; row < m_model->rowCount(); ++row) { @@ -410,6 +487,13 @@ void TableMappingPage::onSave() 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}); @@ -418,6 +502,8 @@ void TableMappingPage::onSave() 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 { @@ -428,21 +514,68 @@ void TableMappingPage::onSave() // 延迟重置保存标记,让文件监控忽略本次写入事件 QTimer::singleShot(2000, this, [this]() { m_isSaving = false; - // 重新添加监控路径 - QString path = getCustJsonPath(); - if (!m_fileWatcher->files().contains(path)) - m_fileWatcher->addPath(path); + // 重新添加监控路径(文件可能被替换, 目录监控在此期间负责兜底) + 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); diff --git a/src/pages/datamapping/tablemappingpage.h b/src/pages/datamapping/tablemappingpage.h index 0801f02..2e17cff 100644 --- a/src/pages/datamapping/tablemappingpage.h +++ b/src/pages/datamapping/tablemappingpage.h @@ -7,9 +7,12 @@ #include #include #include +#include #include "ElaTableView.h" class ElaLineEdit; +class QLabel; +class QTimer; class TableMappingPage : public QWidget { @@ -25,7 +28,10 @@ private slots: 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(); @@ -34,6 +40,8 @@ private: 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; @@ -41,6 +49,10 @@ private: 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/functionsearch/functionsearchpage.cpp b/src/pages/functionsearch/functionsearchpage.cpp deleted file mode 100644 index 478245f..0000000 --- a/src/pages/functionsearch/functionsearchpage.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "functionsearchpage.h" -#include -#include - -FunctionSearchPage::FunctionSearchPage(QWidget *parent) - : QWidget(parent) -{ - initUI(); -} - -FunctionSearchPage::~FunctionSearchPage() -{ -} - -void FunctionSearchPage::initUI() -{ - QVBoxLayout *layout = new QVBoxLayout(this); - layout->setContentsMargins(30, 30, 30, 30); - - QLabel *label = new QLabel("功能查询"); - label->setStyleSheet("font-size: 18px; font-weight: bold;"); - layout->addWidget(label); -} diff --git a/src/pages/functionsearch/functionsearchpage.h b/src/pages/functionsearch/functionsearchpage.h deleted file mode 100644 index df3772c..0000000 --- a/src/pages/functionsearch/functionsearchpage.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef FUNCTIONSEARCHPAGE_H -#define FUNCTIONSEARCHPAGE_H - -#include -#include -#include "ElaLineEdit.h" -#include "ElaPushButton.h" -#include "ElaTableView.h" - -class FunctionSearchPage : public QWidget -{ - Q_OBJECT - -public: - explicit FunctionSearchPage(QWidget *parent = nullptr); - ~FunctionSearchPage(); - -private: - void initUI(); - - ElaLineEdit *m_searchEdit; - ElaTableView *m_resultTable; - QStandardItemModel *m_tableModel; -}; - -#endif diff --git a/src/pages/functionsearch/uf20functionsearchpage.cpp b/src/pages/functionsearch/uf20functionsearchpage.cpp index a2cd74c..54d172f 100644 --- a/src/pages/functionsearch/uf20functionsearchpage.cpp +++ b/src/pages/functionsearch/uf20functionsearchpage.cpp @@ -4,12 +4,33 @@ #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) { initUI(); @@ -35,7 +56,7 @@ void UF20FunctionSearchPage::initUI() m_searchEdit = new ElaSearchEdit; m_searchEdit->setPlaceholderText("输入查询内容"); - m_searchEdit->setFixedSize(330, 35); + 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); @@ -55,16 +76,38 @@ void UF20FunctionSearchPage::initUI() m_resultTable->setMinimumHeight(400); m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows); m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - m_resultTable->setSelectionMode(QAbstractItemView::SingleSelection); + // 扩展多选: 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; - QLabel *tipLabel = new QLabel("💡 双击选中行加入转码工作台:LS功能 → \"全量转码\";其它功能 → \"按需转码\""); - tipLabel->setStyleSheet("color: #666; font-size: 13px;"); - tipLayout->addWidget(tipLabel); + 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) @@ -74,23 +117,43 @@ void UF20FunctionSearchPage::onExactMatchToggled(bool checked) void UF20FunctionSearchPage::onSearch() { - QString keyword = m_searchEdit->text().trimmed(); - + // 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集) + const QStringList keywords = splitKeywords(m_searchEdit->text()); + m_tableModel->removeRows(0, m_tableModel->rowCount()); - - if (keyword.isEmpty()) { - QMessageBox::information(this, "提示", "请输入查询内容"); + + if (keywords.isEmpty()) { + // 空关键字: 页内 tip 反馈, 不弹模态框打断操作 + if (m_tipLabel) { + m_tipLabel->setText("请输入查询内容"); + } return; } - + QString field = m_searchComboBox->currentData().toString(); - QList results = DataCache::instance()->searchUF20Functions(keyword, field, m_exactMatch); - + int noMatchCount = 0; + QList results = + DataCache::instance()->searchUF20Functions(keywords, field, m_exactMatch, &noMatchCount); + if (results.isEmpty()) { - QMessageBox::information(this, "提示", "未找到匹配的功能"); + // 无结果: 页内 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; @@ -112,14 +175,52 @@ void UF20FunctionSearchPage::onSearch() void UF20FunctionSearchPage::onTableDoubleClicked(const QModelIndex &index) { - if (index.isValid()) { - const QString funcName = - m_tableModel->data(m_tableModel->index(index.row(), 0)).toString(); - // LS_ 开头 → 全量转码;其余 → 按需转码(由 MainWindow 接到对应工作台) - if (funcName.startsWith("LS_")) { - emit addLsFunctionToConvert(funcName); - } else { - emit addNonLsFunctionToConvert(funcName); - } + 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); +} diff --git a/src/pages/functionsearch/uf20functionsearchpage.h b/src/pages/functionsearch/uf20functionsearchpage.h index 19d342f..651dce7 100644 --- a/src/pages/functionsearch/uf20functionsearchpage.h +++ b/src/pages/functionsearch/uf20functionsearchpage.h @@ -7,6 +7,8 @@ #include "components/elasearchedit.h" #include "ElaTableView.h" +class QLabel; + class UF20FunctionSearchPage : public QWidget { Q_OBJECT @@ -16,22 +18,29 @@ public: ~UF20FunctionSearchPage(); signals: - // 双击按功能类别分流:LS_ 开头 → 全量转码工作台;其余 → 按需转码工作台 - void addLsFunctionToConvert(const QString& funcName); - void addNonLsFunctionToConvert(const QString& funcName); + // 一键添加(双击/右键共用): 按 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; }; diff --git a/src/pages/functionsearch/uft3functionsearchpage.cpp b/src/pages/functionsearch/uft3functionsearchpage.cpp index f90bd70..d34b5bf 100644 --- a/src/pages/functionsearch/uft3functionsearchpage.cpp +++ b/src/pages/functionsearch/uft3functionsearchpage.cpp @@ -4,12 +4,31 @@ #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(); @@ -34,8 +53,8 @@ void UFT3FunctionSearchPage::initUI() searchLayout->addWidget(m_searchComboBox); m_searchEdit = new ElaSearchEdit; - m_searchEdit->setPlaceholderText("输入查询内容"); - m_searchEdit->setFixedSize(330, 35); + 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); @@ -56,6 +75,30 @@ void UFT3FunctionSearchPage::initUI() 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) @@ -65,23 +108,43 @@ void UFT3FunctionSearchPage::onExactMatchToggled(bool checked) void UFT3FunctionSearchPage::onSearch() { - QString keyword = m_searchEdit->text().trimmed(); - + // 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集) + const QStringList keywords = splitKeywords(m_searchEdit->text()); + m_tableModel->removeRows(0, m_tableModel->rowCount()); - - if (keyword.isEmpty()) { - QMessageBox::information(this, "提示", "请输入查询内容"); + + if (keywords.isEmpty()) { + // 空关键字: 页内 tip 反馈, 不弹模态框打断操作 + if (m_tipLabel) { + m_tipLabel->setText("请输入查询内容"); + } return; } - + QString field = m_searchComboBox->currentData().toString(); - QList results = DataCache::instance()->searchUFT3Functions(keyword, field, m_exactMatch); - + int noMatchCount = 0; + QList results = + DataCache::instance()->searchUFT3Functions(keywords, field, m_exactMatch, &noMatchCount); + if (results.isEmpty()) { - QMessageBox::information(this, "提示", "未找到匹配的功能"); + // 无结果: 页内 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; @@ -96,7 +159,7 @@ void UFT3FunctionSearchPage::onSearch() QStandardItem *funcNoItem = new QStandardItem(info.functionNo); funcNoItem->setFlags(funcNoItem->flags() & ~Qt::ItemIsEditable); rowItems.append(funcNoItem); - + m_tableModel->appendRow(rowItems); } } diff --git a/src/pages/functionsearch/uft3functionsearchpage.h b/src/pages/functionsearch/uft3functionsearchpage.h index caa8b87..d131ba7 100644 --- a/src/pages/functionsearch/uft3functionsearchpage.h +++ b/src/pages/functionsearch/uft3functionsearchpage.h @@ -7,6 +7,8 @@ #include "components/elasearchedit.h" #include "ElaTableView.h" +class QLabel; + class UFT3FunctionSearchPage : public QWidget { Q_OBJECT @@ -18,6 +20,8 @@ public: private slots: void onSearch(); void onExactMatchToggled(bool checked); + // tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随) + void updateTipStyle(); private: void initUI(); @@ -26,6 +30,7 @@ private: ElaSearchEdit *m_searchEdit; ElaTableView *m_resultTable; QStandardItemModel *m_tableModel; + QLabel *m_tipLabel; bool m_exactMatch; }; diff --git a/src/pages/metadatupdate/metadatupdatepage.cpp b/src/pages/metadatupdate/metadatupdatepage.cpp index 423b8be..6b1717b 100644 --- a/src/pages/metadatupdate/metadatupdatepage.cpp +++ b/src/pages/metadatupdate/metadatupdatepage.cpp @@ -3,6 +3,7 @@ #include "utils/logmanager.h" #include "utils/datacache.h" #include "utils/business_strategy.h" +#include "ElaTheme.h" #include #include #include @@ -27,11 +28,19 @@ MetadataUpdatePage::MetadataUpdatePage(QWidget *parent) this, &MetadataUpdatePage::onWorkerFinished); connect(this, &MetadataUpdatePage::sigProgress, this, &MetadataUpdatePage::onUpdateProgress, Qt::QueuedConnection); + + // 卡片背景跟随 Ela 主题(浅色/深色)切换 + updateCardStyle(); + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &MetadataUpdatePage::updateCardStyle); } MetadataUpdatePage::~MetadataUpdatePage() { + // 后台任务 lambda 捕获 this,页面析构前必须等任务结束。 + // 通过协作式取消让递归扫描快速返回,避免长时间无界等待 if (_watcher && _watcher->isRunning()) { + _processor->requestCancel(); _watcher->waitForFinished(); } } @@ -55,13 +64,7 @@ void MetadataUpdatePage::initUI() auto* uft3GroupBox = new QWidget(contentWidget); uft3GroupBox->setObjectName("CardWidget"); - uft3GroupBox->setStyleSheet(R"( - #CardWidget { - background-color: white; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - )"); + m_cardWidgets << uft3GroupBox; // 背景样式由 updateCardStyle() 按主题统一设置 _updateGridLayout = new QGridLayout(uft3GroupBox); _updateGridLayout->setContentsMargins(12, 10, 12, 10); _updateGridLayout->setSpacing(6); @@ -95,13 +98,7 @@ void MetadataUpdatePage::initUI() auto* uf20GroupBox = new QWidget(contentWidget); uf20GroupBox->setObjectName("CardWidget"); - uf20GroupBox->setStyleSheet(R"( - #CardWidget { - background-color: white; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - )"); + m_cardWidgets << uf20GroupBox; // 背景样式由 updateCardStyle() 按主题统一设置 _updateUf2GridLayout = new QGridLayout(uf20GroupBox); _updateUf2GridLayout->setContentsMargins(12, 10, 12, 10); _updateUf2GridLayout->setSpacing(6); @@ -134,7 +131,7 @@ void MetadataUpdatePage::initUI() gridLayout->addItem(new QSpacerItem(5, 5, QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Minimum), 0, 10, 2, 1); _forceFullCheckBox = new ElaCheckBox("强制全量更新", contentWidget); - _forceFullCheckBox->setToolTip("勾选后本次将重新扫描并解析全部接口文件、重建增量缓存;不勾选时仅处理有变化的文件"); + _forceFullCheckBox->setToolTip("勾选后本次强制重新扫描并解析全部接口文件;不勾选时源目录无变化则自动跳过本次扫描"); connect(_forceFullCheckBox, &QCheckBox::toggled, this, [this](bool checked){ if (checked) { auto ret = QMessageBox::question(this, "提示", @@ -172,8 +169,14 @@ void MetadataUpdatePage::showEvent(QShowEvent* event) // 每次显示页面时重新从 ConfigManager 读取路径 // (用户可能已在设置页面修改过路径) + // 更新任务运行中必须跳过: reloadPaths 在主线程写路径成员, 与后台 worker + // 并发读同一 QString 成员构成数据竞争(隐式共享引用计数非线程安全) if (_processor) { - _processor->reloadPaths(); + if (_watcher->isRunning()) { + LogManager::instance()->log("元数据更新任务进行中, 本次显示跳过路径重载"); + } else { + _processor->reloadPaths(); + } } static bool firstShow = true; @@ -184,6 +187,24 @@ void MetadataUpdatePage::showEvent(QShowEvent* event) } } +void MetadataUpdatePage::updateCardStyle() +{ + // 夜间模式用深色卡片背景+描边, 白天模式保持白色卡片(原视觉效果); + // 卡片内复选框文字颜色由 Ela 主题绘制(深色主题为浅色文字), + // 卡片背景跟随主题后, 文字与背景对比度自然恢复正常(修复夜间白字白底不可见)。 + const bool isDark = ElaTheme::getInstance()->getThemeMode() == ElaThemeType::Dark; + const QString cardColor = isDark ? ElaThemeColor(ElaThemeType::Dark, DialogBase).name() + : QStringLiteral("#FFFFFF"); + const QString borderColor = isDark ? ElaThemeColor(ElaThemeType::Dark, BasicBorder).name() + : QStringLiteral("transparent"); + const QString qss = QStringLiteral( + "#CardWidget { background-color: %1; border: 1px solid %2; border-radius: 8px; }") + .arg(cardColor, borderColor); + for (QWidget* card : m_cardWidgets) { + card->setStyleSheet(qss); + } +} + void MetadataUpdatePage::showUpdateItems() { // 注意: "数据类型" 需排在 "标准字段" 之前, 标准字段解析依赖数据类型(从缓存库读取)。 @@ -295,34 +316,43 @@ void MetadataUpdatePage::onUpdateUFT3() UpdateResult MetadataUpdatePage::runUpdateUf2(const QStringList& items) { + // 防御性排序: "数据类型" 必须先于 "标准字段"(标准字段从缓存库读 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 : items) { + for (const QString& item : ordered) { + if (_processor->isCancelRequested()) break; // 页面析构触发的协作式取消 + emit sigProgress((itemCount == 1) ? 0 : (idx * 100 / itemCount), QString("正在更新UF20 - %1").arg(item)); if (item == "标准字段") { - processUf2StdFields(); + if (!processUf2StdFields()) success = false; } else if (item == "数据类型") { - processUf2DataType(); + if (!processUf2DataType()) success = false; } else if (item == "标准错误号") { - processUf2ErrNumber(); + if (!processUf2ErrNumber()) success = false; } else if (item == "接口数据") { if (!processUf2Interface(Constants::toolDir())) { success = false; } interfaceUpdated = true; } else if (item == "常量") { - processUf2Constants(); + if (!processUf2Constants()) success = false; } else if (item == "菜单功能") { - processUf2MenuFunc(); + if (!processUf2MenuFunc()) success = false; } else if (item == "模块") { - processUf2Module(); + if (!processUf2Module()) success = false; } else if (item == "表结构") { - processUf2Table(); + if (!processUf2Table()) success = false; } ++idx; } @@ -333,21 +363,30 @@ UpdateResult MetadataUpdatePage::runUpdateUf2(const QStringList& items) UpdateResult MetadataUpdatePage::runUpdateUft3(const QStringList& items) { + // 防御性排序: "数据类型" 必须先于 "标准字段"(标准字段从缓存库读 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 : items) { + for (const QString& item : ordered) { + if (_processor->isCancelRequested()) break; // 页面析构触发的协作式取消 + emit sigProgress((itemCount == 1) ? 0 : (idx * 100 / itemCount), QString("正在更新UFT30 - %1").arg(item)); if (item == "标准字段") { - processUft3StdFields(); + if (!processUft3StdFields()) success = false; } else if (item == "数据类型") { - processUft3DataType(); + if (!processUft3DataType()) success = false; } else if (item == "表结构") { - processUft3Table(); + if (!processUft3Table()) success = false; } else if (item == "接口数据") { if (!processUft3Interface(Constants::toolDir())) { success = false; @@ -360,19 +399,19 @@ UpdateResult MetadataUpdatePage::runUpdateUft3(const QStringList& items) return UpdateResult{success, interfaceUpdated}; } -void MetadataUpdatePage::processUf2StdFields() +bool MetadataUpdatePage::processUf2StdFields() { - _processor->processUf2StdFields(); + return _processor->processUf2StdFields(); } -void MetadataUpdatePage::processUf2DataType() +bool MetadataUpdatePage::processUf2DataType() { - _processor->processUf2DataType(); + return _processor->processUf2DataType(); } -void MetadataUpdatePage::processUf2ErrNumber() +bool MetadataUpdatePage::processUf2ErrNumber() { - _processor->processUf2ErrNumber(); + return _processor->processUf2ErrNumber(); } bool MetadataUpdatePage::processUf2Interface(const QString& basePath) @@ -380,39 +419,39 @@ bool MetadataUpdatePage::processUf2Interface(const QString& basePath) return _processor->processUf2Interfaces(basePath); } -void MetadataUpdatePage::processUf2Constants() +bool MetadataUpdatePage::processUf2Constants() { - _processor->processUf2Constants(); + return _processor->processUf2Constants(); } -void MetadataUpdatePage::processUf2MenuFunc() +bool MetadataUpdatePage::processUf2MenuFunc() { - _processor->processUf2MenuFunc(); + return _processor->processUf2MenuFunc(); } -void MetadataUpdatePage::processUf2Module() +bool MetadataUpdatePage::processUf2Module() { - _processor->processUf2Module(); + return _processor->processUf2Module(); } -void MetadataUpdatePage::processUf2Table() +bool MetadataUpdatePage::processUf2Table() { - _processor->processUf2Table(); + return _processor->processUf2Table(); } -void MetadataUpdatePage::processUft3StdFields() +bool MetadataUpdatePage::processUft3StdFields() { - _processor->processUft3StdFields(); + return _processor->processUft3StdFields(); } -void MetadataUpdatePage::processUft3DataType() +bool MetadataUpdatePage::processUft3DataType() { - _processor->processUft3DataType(); + return _processor->processUft3DataType(); } -void MetadataUpdatePage::processUft3Table() +bool MetadataUpdatePage::processUft3Table() { - _processor->processUft3Table(); + return _processor->processUft3Table(); } bool MetadataUpdatePage::processUft3Interface(const QString& basePath) @@ -422,23 +461,49 @@ bool MetadataUpdatePage::processUft3Interface(const QString& basePath) void MetadataUpdatePage::startUpdate(const QStringList& items, const QString& taskName) { + // 防御:若上一个任务仍在运行则不再启动(按钮已禁用,正常情况下不会触发)。 + // 必须先于状态改写, 避免防御触发时已污染进度条/任务名等 UI 状态 + if (_watcher->isRunning()) { + return; + } + m_currentTaskName = taskName; _updateTimer.start(); _progressBar->setValue(0); _processLabel->setText(QString("正在准备更新%1...").arg(taskName)); - // 防御:若上一个任务仍在运行则不再启动(按钮已禁用,正常情况下不会触发) - if (_watcher->isRunning()) { - return; - } + _processor->resetCancel(); // 重置取消标志,保证新任务不被上一次的请求误杀 if (taskName == "UF20") { _watcher->setFuture(QtConcurrent::run([this, items]() { - return runUpdateUf2(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]() { - return runUpdateUft3(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}; + } })); } } diff --git a/src/pages/metadatupdate/metadatupdatepage.h b/src/pages/metadatupdate/metadatupdatepage.h index 832877f..5ca2826 100644 --- a/src/pages/metadatupdate/metadatupdatepage.h +++ b/src/pages/metadatupdate/metadatupdatepage.h @@ -51,6 +51,7 @@ signals: private: void initUI(); void showUpdateItems(); + void updateCardStyle(); // 根据 Ela 主题(浅色/深色)刷新卡片背景样式 // 后台线程执行体(由 QtConcurrent 调用,禁止直接操作 UI) UpdateResult runUpdateUf2(const QStringList& items); @@ -58,18 +59,18 @@ private: // 在主线程启动后台任务 void startUpdate(const QStringList& items, const QString& taskName); - void processUf2StdFields(); - void processUf2DataType(); - void processUf2ErrNumber(); - void processUf2Constants(); - void processUf2MenuFunc(); - void processUf2Module(); - void processUf2Table(); + bool processUf2StdFields(); + bool processUf2DataType(); + bool processUf2ErrNumber(); + bool processUf2Constants(); + bool processUf2MenuFunc(); + bool processUf2Module(); + bool processUf2Table(); bool processUf2Interface(const QString& basePath); - void processUft3StdFields(); - void processUft3DataType(); - void processUft3Table(); + bool processUft3StdFields(); + bool processUft3DataType(); + bool processUft3Table(); bool processUft3Interface(const QString& basePath); QGridLayout* _updateGridLayout{nullptr}; @@ -86,6 +87,7 @@ private: QList uft3CheckBoxes; QList uf20CheckBoxes; + QList m_cardWidgets; // 需要跟随主题切换背景的卡片 MetadataProcessor* _processor{nullptr}; QFutureWatcher* _watcher{nullptr}; diff --git a/src/pages/nonlsconvert/nonlsconvertpage.cpp b/src/pages/nonlsconvert/nonlsconvertpage.cpp index 6863500..1e41994 100644 --- a/src/pages/nonlsconvert/nonlsconvertpage.cpp +++ b/src/pages/nonlsconvert/nonlsconvertpage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ #include #include "ElaPushButton.h" #include "ElaComboBox.h" +#include "ElaTheme.h" namespace { @@ -109,6 +111,11 @@ NonLsConvertPage::NonLsConvertPage(QWidget *parent) m_lastFileMtime = currentFileMtime(); setupConfigWatcher(); + + // 灰字说明/进度标签跟随主题明暗切换 + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &NonLsConvertPage::updateTipStyle); + updateTipStyle(); } NonLsConvertPage::~NonLsConvertPage() @@ -126,17 +133,16 @@ void NonLsConvertPage::initUI() layout->setSpacing(14); // 按需转码说明条:非 LS 功能,支持单文件/递归两种模式,不含核心文件与 module 模块文件 - QLabel *modeTipLabel = new QLabel( + m_tipLabel = new QLabel( "说明:按需转码针对非 LS 功能转码,支持单文件转码与递归转码两种模式," "转码范围不包含核心文件、module 模块文件。" "
    " "
  • 非递归模式:仅对目标文件本身执行转码,不会处理该文件内调用的函数;
  • " "
  • 递归模式:转码目标文件的同时,自动对文件中所调用的关联函数一并完成转码。
  • " "
"); - modeTipLabel->setTextFormat(Qt::RichText); - modeTipLabel->setStyleSheet("color: #666; font-size: 12px;"); - modeTipLabel->setWordWrap(true); - layout->addWidget(modeTipLabel); + m_tipLabel->setTextFormat(Qt::RichText); + m_tipLabel->setWordWrap(true); + layout->addWidget(m_tipLabel); QGroupBox *funcBox = new QGroupBox("转码功能"); QVBoxLayout *funcLayout = new QVBoxLayout(funcBox); @@ -179,7 +185,6 @@ void NonLsConvertPage::initUI() progressLayout->setSpacing(10); m_progressLabel = new ElaText("等待开始..."); - m_progressLabel->setStyleSheet("color: #666; font-size: 12px;"); m_progressLabel->setFixedHeight(40); m_progressLabel->setWordWrap(false); m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); @@ -289,9 +294,13 @@ void NonLsConvertPage::updateTable() editBtn->setIconSize(QSize(20, 20)); editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;"); editBtn->setToolTip("修改"); - connect(editBtn, &QPushButton::clicked, this, [this, i]() { - onEditRow(i); - }); + // 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); } @@ -330,6 +339,16 @@ void NonLsConvertPage::showEvent(QShowEvent *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()) { @@ -377,6 +396,8 @@ void NonLsConvertPage::onEditRow(int row) updateTable(); if (saveToCustNonLsJson(m_funcList)) { loadFromCustNonLsJson(); + } else { + QMessageBox::warning(this, "保存失败", "函数名已在内存中修改,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后修改将丢失!"); } adviseAboutLsNames(this, {newName}); } @@ -444,7 +465,9 @@ void NonLsConvertPage::onAddFunction() updateTable(); if (addedCount > 0) { - saveToCustNonLsJson(m_funcList); + if (!saveToCustNonLsJson(m_funcList)) { + QMessageBox::warning(this, "保存失败", "新增函数已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将丢失!"); + } } } @@ -473,6 +496,8 @@ void NonLsConvertPage::onRemoveFunction() updateTable(); if (saveToCustNonLsJson(m_funcList)) { loadFromCustNonLsJson(); + } else { + QMessageBox::warning(this, "保存失败", "删除操作已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将恢复原清单!"); } } @@ -491,10 +516,25 @@ void NonLsConvertPage::onClearTable() 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)); + } +} + bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) { const QString jsonPath = Constants::custNonLsJson(); @@ -503,7 +543,14 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) QJsonObject root; { QFile file(jsonPath); - if (file.exists() && file.open(QIODevice::ReadOnly)) { + if (file.exists()) { + // 文件存在但无法打开(被占用/权限): 覆盖写会吞掉原内容, 中止保存保留原文件 + if (!file.open(QIODevice::ReadOnly)) { + LogManager::instance()->logWarning( + QString("按需转码 - 保存前读取既有配置失败(无法打开),已中止保存: %1") + .arg(jsonPath)); + return false; + } QByteArray data = file.readAll(); file.close(); QJsonParseError parseError; @@ -511,9 +558,11 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) if (parseError.error == QJsonParseError::NoError && doc.isObject()) { root = doc.object(); } else { + // 解析失败: 覆盖写会丢失 convert 外的顶层节点且不可恢复, 中止保存保留原文件 LogManager::instance()->logWarning( - QString("按需转码 - 保存前读取既有配置失败(%1),将按当前清单重建: %2") + QString("按需转码 - 既有配置解析失败(%1),已中止保存避免覆盖: %2") .arg(parseError.errorString()).arg(jsonPath)); + return false; } } } @@ -543,7 +592,9 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) convertObj["ext"] = extArray; root["convert"] = convertObj; - QFile file(jsonPath); + // QSaveFile 原子写: 先写临时文件, commit 成功才替换目标文件, + // 避免写入中途崩溃/断电损坏配置 + QSaveFile file(jsonPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { LogManager::instance()->logWarning( QString("按需转码 - 保存配置失败,无法写入: %1").arg(jsonPath)); @@ -551,7 +602,11 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList) } QJsonDocument doc(root); file.write(doc.toJson(QJsonDocument::Indented)); - file.close(); + if (!file.commit()) { + LogManager::instance()->logWarning( + QString("按需转码 - 保存配置失败,写入提交异常: %1").arg(jsonPath)); + return false; + } // 程序自身写入后更新 mtime,避免触发无谓的重载 m_lastFileMtime = currentFileMtime(); @@ -666,6 +721,14 @@ void NonLsConvertPage::onConvertFinished() 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) { @@ -703,6 +766,48 @@ void NonLsConvertPage::addFunction(const QString &funcName) } } +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); +} + QStringList NonLsConvertPage::buildArguments(bool *ok) { if (ok) { diff --git a/src/pages/nonlsconvert/nonlsconvertpage.h b/src/pages/nonlsconvert/nonlsconvertpage.h index c752571..512ef8a 100644 --- a/src/pages/nonlsconvert/nonlsconvertpage.h +++ b/src/pages/nonlsconvert/nonlsconvertpage.h @@ -9,10 +9,13 @@ #include #include #include +#include #include "ElaTableView.h" #include "ElaProgressBar.h" #include "ElaText.h" +class QLabel; + // 按需转码工作台(业务转码页 Pivot2) // 交互与全量转码页(BatchConvertPage)保持一致: // 函数清单(短名)与“是否递归”开关 -> 持久化到 config/cust_nonls.json @@ -35,7 +38,12 @@ 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); @@ -50,9 +58,11 @@ private slots: 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(); @@ -69,6 +79,7 @@ private: ElaTableView *m_funcTable; QStandardItemModel *m_tableModel; + QLabel *m_tipLabel = nullptr; // 页首灰字说明条(需主题联动) QStringList m_funcList; // 每个清单功能对应的“是否递归”标志:0=否、-1=是(与 cust_nonls.json convert.dir 值一致) QHash m_recursionMap; diff --git a/src/pages/settings/settingspage.cpp b/src/pages/settings/settingspage.cpp index 9120d86..d919d0d 100644 --- a/src/pages/settings/settingspage.cpp +++ b/src/pages/settings/settingspage.cpp @@ -1,4 +1,4 @@ -#include "settingspage.h" +#include "settingspage.h" #include "utils/Constants.h" #include #include @@ -9,12 +9,16 @@ #include #include #include +#include #include #include +#include #include "ElaLineEdit.h" #include "ElaComboBox.h" #include "ElaPushButton.h" +#include "ElaTheme.h" #include "src/utils/configmanager.h" +#include "src/utils/logmanager.h" SettingsPage::SettingsPage(QWidget *parent) : QWidget(parent) @@ -127,6 +131,28 @@ void SettingsPage::initUI() // 添加弹性空间 layout->addStretch(); + + // 原生控件(QLabel/QGroupBox)不随 Ela 主题变化, 按当前主题设置样式, + // 并跟随主题切换信号实时刷新(修复夜间模式文字不可读、白色边框刺眼) + updateThemeStyle(); + connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged, + this, &SettingsPage::updateThemeStyle); +} + +void SettingsPage::updateThemeStyle() +{ + // 修复夜间模式下原生 QLabel(黑色文字)几乎不可读、QGroupBox(白色边框)刺眼的问题: + // 原生控件颜色不随 Ela 主题切换, 此处按当前主题统一设置文字与边框颜色。 + // 选择器限定在 QGroupBox 内部, 不影响 QMessageBox/QFileDialog 等原生弹窗。 + const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode(); + const QString textColor = ElaThemeColor(mode, BasicText).name(); + const QString borderColor = ElaThemeColor(mode, BasicBorder).name(); + setStyleSheet(QStringLiteral( + "QGroupBox { color: %1; border: 1px solid %2; border-radius: 8px; margin-top: 12px; }" + "QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left;" + " left: 10px; padding: 0 4px; }" + "QGroupBox QLabel { color: %1; background: transparent; }") + .arg(textColor, borderColor)); } void SettingsPage::loadSettings() @@ -134,7 +160,8 @@ void SettingsPage::loadSettings() loadFromConfigIni(); QSettings settings("UFT30", "ChangeCode"); - QString theme = settings.value("Theme", "日间模式").toString(); + // 默认"夜间模式"与 Ela 默认主题一致, 保证下拉框显示值与界面实际主题相符 + QString theme = settings.value("Theme", "夜间模式").toString(); int index = m_themeCombo->findText(theme); if (index >= 0) { m_themeCombo->setCurrentIndex(index); @@ -295,16 +322,22 @@ void SettingsPage::saveToConfigIni() newLines << ("createPath=" + createPathVal); } - QFile outFile(iniPath); - if (outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - QByteArray outData; - if (isUtf8) { - outData = newLines.join("\n").toUtf8(); - } else { - outData = newLines.join("\n").toLocal8Bit(); - } - outFile.write(outData); - outFile.close(); + // 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)); } } @@ -319,7 +352,39 @@ void SettingsPage::onSaveSettings() QString uf20Val = m_uf20PathEdit->text().trimmed().replace("/", "\\"); QString ufAcct20Val = m_uf20AccountPathEdit->text().trimmed().replace("/", "\\"); QString createPathVal = m_outputPathEdit->text().trimmed().replace("/", "\\"); - + + // 输入校验: 逐项检查非空与目录存在性; uft30pub 为派生路径一并探测 + QStringList problems; + const QList> pathChecks = { + {"UFT30项目环境路径", uft30Val}, + {"UF20项目环境路径", uf20Val}, + {"UF20账户项目环境路径", ufAcct20Val}, + {"转码生成路径", createPathVal}, + }; + for (const auto &check : pathChecks) { + if (check.second.isEmpty()) { + problems << QString("%1: 未填写").arg(check.first); + } else if (!QDir(check.second).exists()) { + problems << QString("%1: 目录不存在 (%2)").arg(check.first, check.second); + } + } + if (uft30Val.isEmpty()) { + problems << "UFT30元数据路径(uft30pub): 依赖 UFT30项目环境路径, 当前无法生成"; + } else if (!QDir(uft30Val + "\\upub\\dev_codes").exists()) { + problems << QString("UFT30元数据路径: %1 不存在").arg(uft30Val + "\\upub\\dev_codes"); + } + + if (!problems.isEmpty()) { + QMessageBox::StandardButton reply = + QMessageBox::question(this, "路径校验未通过", + "以下配置存在问题:\n - " + problems.join("\n - ") + + "\n\n仍要保存吗?", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (reply != QMessageBox::Yes) { + return; + } + } + configManager->setValue("projectPath", "uft30", uft30Val); configManager->setValue("projectPath", "uf20", uf20Val); configManager->setValue("projectPath", "ufAcct20", ufAcct20Val); @@ -327,8 +392,14 @@ void SettingsPage::onSaveSettings() QString uft30PubVal = uft30Val + "\\upub\\dev_codes"; configManager->setValue("projectPath", "uft30pub", uft30PubVal); - - configManager->saveConfig(); + + // 检查落盘结果: 磁盘满/只读/父目录被删时 QSaveFile 打开或 commit 都会失败, + // 不检查会让 UI 谎报成功, 用户重启后路径全部丢失 + if (!configManager->saveConfig()) { + QMessageBox::critical(this, "保存失败", + "写入 config.ini 失败(磁盘空间不足、目录只读或被占用?),\n本次修改未持久化,重启后将丢失!"); + return; + } QMessageBox::information(this, "保存成功", "配置已保存!"); } @@ -344,11 +415,23 @@ void SettingsPage::onResetSettings() m_uf20AccountPathEdit->clear(); m_outputPathEdit->clear(); m_themeCombo->setCurrentIndex(0); + // setCurrentIndex 不触发 onThemeChanged, 手动调用切回日间主题 + // (UI 显示"日间模式"但 ElaTheme 仍是原值的不一致状态), 并写回 QSettings + onThemeChanged(); saveToConfigIni(); + // 同步 ConfigManager 内存缓存: 不同步的话其他页面 getValue 仍得重置前的旧路径 + ConfigManager *configManager = ConfigManager::instance(); + configManager->setValue("projectPath", "uft30", ""); + configManager->setValue("projectPath", "uf20", ""); + configManager->setValue("projectPath", "ufAcct20", ""); + configManager->setValue("projectPath", "createPath", ""); + QSettings settings("UFT30", "ChangeCode"); settings.clear(); + // clear 会把主题键一并清掉, 重置后把当前主题(日间)写回, 重启后保持一致 + settings.setValue("Theme", m_themeCombo->currentText()); QMessageBox::information(this, "重置成功", "已恢复默认设置!"); } } @@ -387,4 +470,13 @@ void SettingsPage::onSelectOutputPath() void SettingsPage::onThemeChanged() { + // 下拉选择即生效: 切换 Ela 主题模式(此前该函数为空, 主题从未被应用, + // 界面始终为 Ela 默认的夜间模式, 与下拉框显示的"日间模式"不符); + // 各页面通过 themeModeChanged 信号实时刷新样式 + ElaTheme::getInstance()->setThemeMode( + m_themeCombo->currentIndex() == 1 ? ElaThemeType::Dark : ElaThemeType::Light); + // 直接持久化: 原只在"保存设置"时写 QSettings, 只切主题不点保存时 + // 重启后主题回退到默认值, 与用户选择不符 + QSettings settings("UFT30", "ChangeCode"); + settings.setValue("Theme", m_themeCombo->currentText()); } diff --git a/src/pages/settings/settingspage.h b/src/pages/settings/settingspage.h index ff6028d..17b4435 100644 --- a/src/pages/settings/settingspage.h +++ b/src/pages/settings/settingspage.h @@ -26,6 +26,7 @@ private slots: private: void initUI(); void loadSettings(); + void updateThemeStyle(); // 按 Ela 主题(浅色/深色)刷新原生 QLabel/QGroupBox 样式 void saveToConfigIni(); void loadFromConfigIni(); QString getConfigIniPath(); diff --git a/src/pythonrunner/PythonRunner.cpp b/src/pythonrunner/PythonRunner.cpp index a10c586..9d35c6e 100644 --- a/src/pythonrunner/PythonRunner.cpp +++ b/src/pythonrunner/PythonRunner.cpp @@ -5,10 +5,14 @@ PythonRunner::PythonRunner(QObject *parent) , m_runMode(AutoDetect) , m_pythonPath("python") , m_process(new QProcess(this)) + , m_killTimer(nullptr) { connect(m_process, &QProcess::started, this, &PythonRunner::started); connect(m_process, QOverload::of(&QProcess::finished), this, &PythonRunner::finished); + // 进程结束后冲刷残留的不完整序列, 避免最后一块输出丢失 + connect(m_process, QOverload::of(&QProcess::finished), + this, &PythonRunner::flushPendingOutput); connect(m_process, &QProcess::readyReadStandardOutput, this, &PythonRunner::onReadyReadStandardOutput); connect(m_process, &QProcess::readyReadStandardError, @@ -17,11 +21,25 @@ PythonRunner::PythonRunner(QObject *parent) 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(); } + }); } PythonRunner::~PythonRunner() { - stop(); + if (m_killTimer) { + m_killTimer->stop(); + } + // 析构时不能留孤儿进程: 直接 kill 并有界等待(不再走 stop() 的异步升级, + // 对象即将销毁, 定时器回调无处可投递) + if (m_process->state() != QProcess::NotRunning) { + m_process->kill(); + m_process->waitForFinished(3000); + } } void PythonRunner::setRunMode(RunMode mode) @@ -89,7 +107,8 @@ bool PythonRunner::canRun() const bool PythonRunner::start() { - if (m_process->state() == QProcess::Running) { + // Starting(异步启动中)也不允许重复发起, 覆盖 Running 检查的盲区 + if (m_process->state() != QProcess::NotRunning) { return false; } @@ -98,6 +117,12 @@ bool PythonRunner::start() QString program; QStringList arguments; + // 强制子进程 stdio 输出 UTF-8: Windows 管道下 Python 默认按 locale(GBK)输出, + // UTF-8/GBK 混流无法可靠判定编码, 统一后解码只需处理 UTF-8 + // (仅影响 stdio, 不改变脚本读写文件的默认编码) + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert("PYTHONIOENCODING", "utf-8"); + if (effectiveMode == UsePackagedExe) { program = m_packagedExePath; arguments = m_arguments; @@ -105,7 +130,6 @@ bool PythonRunner::start() QString exeDir = exeInfo.absolutePath(); m_process->setWorkingDirectory(exeDir); - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert("UF2TOUFT3_HOME", exeDir); m_process->setProcessEnvironment(env); } else { @@ -114,21 +138,50 @@ bool PythonRunner::start() if (!m_workingDir.isEmpty()) { m_process->setWorkingDirectory(m_workingDir); } + m_process->setProcessEnvironment(env); } m_process->start(program, arguments); - return m_process->waitForStarted(3000); + // 异步启动: 不在主线程 waitForStarted(3000) 阻塞 UI(杀软扫描 exe / 慢速磁盘时可达 3s)。 + // 同步启动失败检测(关键): Windows 上 CreateProcess 失败(exe 缺失/被占用)时 QProcess + // 在本调用栈内同步发出 errorOccurred(FailedToStart) 且不补发 finished(Qt 6.6 源码 + // qprocess_win.cpp: 先 cleanup() 置回 NotRunning, 再 setErrorAndEmit), 此时 state 已 + // 回落 NotRunning——必须返回 false 让调用方走启动失败收尾; 否则该错误信号早于调用方 + // 置位"任务在途"标志, 会被幂等守卫吞掉, 导致 UI 永久卡运行态。 + // error() 判据兜底: QProcessPrivate::start 每次入口都重置 processError, 无残留误判。 + if (m_process->state() == QProcess::NotRunning + || m_process->error() == QProcess::FailedToStart) { + return false; + } + // 启动流程已受理: 结果由 started / finished 信号异步上报。 + // runSync 同步语义不受影响: 同步失败时 state 为 NotRunning, waitForFinished 立即返回 false + return true; } void PythonRunner::stop() { - if (m_process->state() == QProcess::Running) { - m_process->terminate(); - if (!m_process->waitForFinished(3000)) { - m_process->kill(); - m_process->waitForFinished(); - } + // Starting(异步启动中)也必须处理: 只查 Running 会漏掉刚发起尚未进入 Running 的进程, + // 稍后启动成功后变成无法停止的孤儿进程 + if (m_process->state() == QProcess::NotRunning) { + return; + } + m_process->terminate(); + // 异步升级: terminate 是"礼貌请求", 进程可能长期忽略。3s 后仍未退出则升级为 kill。 + // 不在主线程 waitForFinished, 避免卡死 UI; 进程若提前退出, 定时器触发时 state 已是 + // NotRunning, 回调自然空转 + if (m_killTimer == nullptr) { + m_killTimer = new QTimer(this); + m_killTimer->setSingleShot(true); + connect(m_killTimer, &QTimer::timeout, this, &PythonRunner::onKillTimeout); + } + m_killTimer->start(3000); +} + +void PythonRunner::onKillTimeout() +{ + if (m_process->state() != QProcess::NotRunning) { + m_process->kill(); } } @@ -137,30 +190,74 @@ bool PythonRunner::isRunning() const return m_process->state() == QProcess::Running; } +bool PythonRunner::isActive() const +{ + return m_process->state() != QProcess::NotRunning; +} + bool PythonRunner::runSync(int timeout) { if (!start()) { return false; } - return m_process->waitForFinished(timeout); + 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() { - QByteArray output = m_process->readAllStandardOutput(); - QString result = QString::fromUtf8(output); - if (result.contains(QChar(0xFFFD))) { - result = QString::fromLocal8Bit(output); - } - emit standardOutput(result); + decodeAndEmit(m_process->readAllStandardOutput(), m_stdoutPending, true); } void PythonRunner::onReadyReadStandardError() { - QByteArray error = m_process->readAllStandardError(); - QString result = QString::fromUtf8(error); - if (result.contains(QChar(0xFFFD))) { - result = QString::fromLocal8Bit(error); - } - emit standardError(result); + decodeAndEmit(m_process->readAllStandardError(), m_stderrPending, false); +} + +void PythonRunner::decodeAndEmit(const QByteArray& chunk, QByteArray& pending, bool isStdout) +{ + // 多字节字符可能被管道分块截断, 逐块独立解码会产生 U+FFFD 乱码: + // 累积到 pending, 只解码完整 UTF-8 序列, 尾部不完整序列留待下一块 + pending += chunk; + int end = pending.size(); + int i = end - 1; + int cont = 0; + while (i >= 0 && (pending.at(i) & 0xC0) == 0x80) { ++cont; --i; } + if (i >= 0 && (pending.at(i) & 0x80) != 0) { + const uchar lead = static_cast(pending.at(i)); + const int need = (lead & 0xE0) == 0xC0 ? 2 + : (lead & 0xF0) == 0xE0 ? 3 + : (lead & 0xF8) == 0xF0 ? 4 + : 1; // 非法首字节: 交由解码器产出 U+FFFD, 不等待 + if (cont + 1 < need) { + end = i; // 尾部序列不完整, 本轮不解码 + } + } + if (end > 0) { + const QString text = QString::fromUtf8(pending.constData(), end); + if (isStdout) { emit standardOutput(text); } else { emit standardError(text); } + } + if (end < pending.size()) { + pending = pending.mid(end); + } else { + pending.clear(); + } +} + +void PythonRunner::flushPendingOutput() +{ + // 进程已结束, 残留序列不会再有后续数据, 直接解码发出 + if (!m_stdoutPending.isEmpty()) { + emit standardOutput(QString::fromUtf8(m_stdoutPending)); + m_stdoutPending.clear(); + } + if (!m_stderrPending.isEmpty()) { + emit standardError(QString::fromUtf8(m_stderrPending)); + m_stderrPending.clear(); + } } diff --git a/src/pythonrunner/PythonRunner.h b/src/pythonrunner/PythonRunner.h index d260630..05de59c 100644 --- a/src/pythonrunner/PythonRunner.h +++ b/src/pythonrunner/PythonRunner.h @@ -7,6 +7,7 @@ #include #include #include +#include class PythonRunner : public QObject { @@ -54,6 +55,9 @@ public: // 检查是否正在运行 bool isRunning() const; + // 含 Starting/Stopping 的宽判定: start() 已异步化, 启动窗口期(杀软扫描可达 3s) + // isRunning() 仍为 false, 停止/防重入必须用本判定否则点击静默无效 + bool isActive() const; // 同步执行 bool runSync(int timeout = 300000); @@ -70,6 +74,9 @@ private slots: void onReadyReadStandardOutput(); void onReadyReadStandardError(); +private slots: + void onKillTimeout(); // stop() 后延时升级 kill 的回调 + private: RunMode m_runMode; QString m_packagedExePath; @@ -78,9 +85,14 @@ private: QString m_workingDir; QStringList m_arguments; QProcess *m_process; + QTimer *m_killTimer; // terminate 后延时升级 kill 的单发定时器 + QByteArray m_stdoutPending; // 跨 readyRead 分块的不完整 UTF-8 序列 + QByteArray m_stderrPending; // 自动检测可用的运行方式 RunMode detectRunMode() const; + void decodeAndEmit(const QByteArray& chunk, QByteArray& pending, bool isStdout); + void flushPendingOutput(); }; #endif // PYTHONRUNNER_H diff --git a/src/utils/callchainanalyzer.cpp b/src/utils/callchainanalyzer.cpp index 57b744f..db5ceea 100644 --- a/src/utils/callchainanalyzer.cpp +++ b/src/utils/callchainanalyzer.cpp @@ -15,6 +15,8 @@ #include #include +#include "logmanager.h" + CallChainAnalyzer::CallChainAnalyzer(QObject *parent) : QObject(parent) { @@ -37,10 +39,16 @@ static bool shouldPrefer(const CallChainAnalyzer::FunctionInfo *existing, return false; // 同优先级,保留先入者 } -// 索引时排除的目录(框架/平台目录,无需纳入调用链分析,且体量大、扫描慢) -static bool isExcludedDir(const QString &filePath) +// 索引时排除的目录(框架/平台目录,无需纳入调用链分析,且体量大、扫描慢)。 +// 只对相对 rootPath 的路径段匹配: 原实现对完整路径拆段匹配, 工程 rootPath 本身 +// 恰为 .../toolset 时会把整个根目录误排除, 导致索引恒为空 +static bool isExcludedDir(const QString &rootPath, const QString &filePath) { - const QStringList parts = QDir::fromNativeSeparators(filePath) + const QString rel = QDir(rootPath).relativeFilePath(filePath); + if (rel.isEmpty() || rel.startsWith("..")) { + return false; // 相对化失败(路径不在 root 下)时交由调用方兜底, 不误伤 + } + const QStringList parts = QDir::fromNativeSeparators(rel) .toLower().split('/', Qt::SkipEmptyParts); for (const QString &part : parts) { if (part == "toolset" || part == "ldp_platform") { @@ -105,31 +113,40 @@ bool CallChainAnalyzer::buildIndex(QString *errorMessage) }; int count = 0; - for (const QString &ext : kScanExtensions) { - QDirIterator it(m_rootPath, QStringList() << ext, QDir::Files, QDirIterator::Subdirectories); - while (it.hasNext()) { - const QString filePath = it.next(); - if (isExcludedDir(filePath)) { - continue; - } - QSharedPointer info = parseFile(filePath); - if (!info || info->chineseName.isEmpty()) { - continue; - } - - // 按 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); - } - - ++count; + // 单遍 QDirIterator 整体名字过滤: 原按 5 种扩展名循环 5 遍全树遍历, + // 目录树大时扫描耗时 5 倍放大 + QDirIterator it(m_rootPath, kScanExtensions, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString filePath = it.next(); + if (isExcludedDir(m_rootPath, filePath)) { + continue; } + QSharedPointer info = parseFile(filePath); + if (!info) { + // 解析失败(XML 损坏/格式不符)静默缺席会让调用链在叶子处断掉且无排查线索 + LogManager::instance()->logWarning( + QString("调用链索引 - 文件解析失败, 已跳过: %1").arg(filePath)); + continue; + } + if (info->chineseName.isEmpty()) { + // .extinterface 声明文件合法缺 chineseName, 常见情况降级为 logInfo + LogManager::instance()->logInfo( + QString("调用链索引 - 文件缺少 chineseName, 未按名称索引: %1").arg(filePath)); + continue; + } + + // 按 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); + } + + ++count; } m_indexed = true; @@ -157,7 +174,7 @@ QString CallChainAnalyzer::computeFingerprint(const QString &rootPath) QDirIterator it(rootPath, kScanExtensions, QDir::Files, QDirIterator::Subdirectories); while (it.hasNext()) { const QFileInfo fi(it.next()); - if (isExcludedDir(fi.filePath())) { + if (isExcludedDir(rootPath, fi.filePath())) { continue; } totalSize += fi.size(); @@ -201,7 +218,7 @@ QString CallChainAnalyzer::quickFingerprint(const QString &rootPath) parts.reserve(entries.size()); for (const QFileInfo &fi : entries) { // 排除目录不计入指纹,与索引扫描范围保持一致 - if (fi.isDir() && isExcludedDir(fi.filePath())) { + if (fi.isDir() && isExcludedDir(rootPath, fi.filePath())) { continue; } // 名称 + 修改时间 + 大小:可感知新增/删除/改名/修改 @@ -246,23 +263,33 @@ void CallChainAnalyzer::saveToCache() const const QString cacheFile = cacheFilePathFor(m_rootPath); QDir().mkpath(QFileInfo(cacheFile).absolutePath()); + // 合并三个索引的全部条目(按对象指针去重): 只遍历 chineseNameIndex 会把 + // shouldPrefer 落选的同名变体丢掉(该变体可能仍挂在 fullName/canonicalName + // 索引下), 重启后 loadFromCache 重建的索引与内存不一致 + QSet seenInfos; QJsonArray funcs; - for (auto it = m_chineseNameIndex.constBegin(); it != m_chineseNameIndex.constEnd(); ++it) { - const FunctionInfo *info = it.value().data(); - 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); + 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); } - o["calledFunctions"] = calls; - funcs.append(o); } QJsonObject meta; @@ -275,9 +302,16 @@ void CallChainAnalyzer::saveToCache() const root["functions"] = funcs; QSaveFile file(cacheFile); - if (file.open(QIODevice::WriteOnly)) { + if (!file.open(QIODevice::WriteOnly)) { + // 打开/提交失败不告警的话, 磁盘满或目录只读时缓存永远失效, 每次启动重扫全树 + LogManager::instance()->logError( + QString("调用链索引 - 缓存文件打开失败(磁盘空间/权限?): %1").arg(cacheFile)); + } else { file.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); - file.commit(); + if (!file.commit()) { + LogManager::instance()->logError( + QString("调用链索引 - 缓存提交失败(磁盘空间/权限?): %1").arg(cacheFile)); + } } // 写入轻量指纹 sidecar,供下次启动做毫秒级粗判,避免整目录递归扫描 @@ -285,9 +319,16 @@ void CallChainAnalyzer::saveToCache() const side["rootPath"] = m_rootPath; side["quick"] = quickFingerprint(m_rootPath); QSaveFile sf(metaFilePathFor(m_rootPath)); - if (sf.open(QIODevice::WriteOnly)) { + // sidecar 只是启动优化, 失败降级为告警(下次启动走全量校验, 功能不受影响) + if (!sf.open(QIODevice::WriteOnly)) { + LogManager::instance()->logWarning( + QString("调用链索引 - 指纹 sidecar 打开失败: %1").arg(metaFilePathFor(m_rootPath))); + } else { sf.write(QJsonDocument(side).toJson(QJsonDocument::Compact)); - sf.commit(); + if (!sf.commit()) { + LogManager::instance()->logWarning( + QString("调用链索引 - 指纹 sidecar 提交失败: %1").arg(metaFilePathFor(m_rootPath))); + } } } @@ -412,22 +453,10 @@ CallChainAnalyzer::lookupBest(const QString &name) const } return nullptr; }, - // 5) chineseName 前缀匹配(如 LS_证券周边_大约可买获取 匹配 ..._转码) - // name 作为前缀只在它是完整函数名(非已调用子函数名)时才合理, - // 因此仅接受 name 后紧跟 "_转码" 后缀的情况,避免误匹配。 - [&]() -> QSharedPointer { - if (name.length() >= 4) { - for (auto it = m_chineseNameIndex.constBegin(); - it != m_chineseNameIndex.constEnd(); ++it) { - if (it.key().startsWith(name) - && it.key().mid(name.length()) == transSuffix) { - return it.value(); - } - } - } - return nullptr; - }, }; + // 注: 原策略5("前缀+_转码"遍历整表线性扫描)与策略3数学恒等—— + // 满足 startsWith(name) && mid(name.length())==suffix 的 key 必然等于 name+suffix, + // 直接哈希查找即可, 删除可避免索引变大后每次 lookupBest 的 O(N) 全表扫描。 for (const Strategy &strategy : strategies) { if (auto info = strategy()) { @@ -471,7 +500,7 @@ void CallChainAnalyzer::resolveNode(const QString &name, TreeNode &node, bool is // 使用多策略查找,而非仅 chineseName 精确匹配 QSharedPointer info = lookupBest(name); if (info) { - node.info = info.data(); + node.info = info; // 共享持有(QSharedPointer), 索引重建后节点不悬垂 node.objectId = info->objectId; node.displayName = info->objectId.isEmpty() ? info->chineseName @@ -508,6 +537,10 @@ void CallChainAnalyzer::buildNode(const QString &name, TreeNode &node, return; } + // visited 为全局共享(不回溯): 每个函数只完整展开一次, 再次遇到直接截断为 cycle 节点, + // 展示语义为“该子树已在其它分支展开”。原 per-path 回溯(insert/remove)在菱形/网状 + // 调用图下按“根→叶不同路径数”重复枚举共享子树, 节点数随深度指数增长导致 UI 卡死; + // 全局共享后节点总数受索引规模(总边数)约束。 if (visited.contains(name)) { node.state = "cycle"; return; @@ -531,8 +564,6 @@ void CallChainAnalyzer::buildNode(const QString &name, TreeNode &node, buildNode(call, child, visited, depth + 1, maxDepth); node.children.append(child); } - - visited.remove(name); } void CallChainAnalyzer::rebuildReverseIndex() @@ -570,7 +601,10 @@ QList CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &downTree, int maxDepth) const { - QList roots; + // 链数硬上限: 反向索引含菱形汇聚/大环时, per-path 枚举会指数爆炸; + // 保证 UI 线程计算有上界, 超出部分以 depthlimit 单节点收尾。 + static const int kMaxChains = 200; + int chainCount = 0; QSet visited; // 将 startName 归一化为其在索引中的 chineseName,保证与反向索引键一致。 @@ -587,7 +621,11 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d // 链末端(叶子)为 name 的调用链;然后把 name 作为被调用方挂到链末端之下, // 使调用方 -> 被调用方(含 startKey 的下游树)保持父子层级,而非平级。 // 例:A>B>C,查询 C(下游 D>E>F)-> 应得到 A>B>C>D>E>F,而不是 A>B 与 C>D>E>F 平级。 - auto recurse = [&](auto &self, const QString &name, QSet vis, int depth) -> QList { + // + // visited 为全局共享(不回溯): 每个上游函数只完整展开一次, 再次遇到直接截断, + // 消除原实现(per-path QSet 每层按值拷贝)在菱形汇聚/大环下的指数级路径枚举; + // 截断节点仍带正常解析信息并以 cycle 标记, 展示语义为“其上游已在其它链中展示”。 + auto recurse = [&](auto &self, const QString &name, int depth) -> QList { QList out; if (depth >= maxDepth) { @@ -596,6 +634,15 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d return out; } + // 全局去重: name 已由其它链展开过时截断为单节点, 不重复枚举其整棵上游子树 + if (name != startKey && visited.contains(name)) { + TreeNode cn = makeNode(name, false); + cn.state = "cycle"; + out.append(cn); + return out; + } + visited.insert(name); + const auto it = m_reverseIndex.find(name); QList> callers; if (it != m_reverseIndex.end()) { @@ -611,17 +658,15 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d for (const QSharedPointer &C : callers) { const QString cName = C->chineseName; - if (vis.contains(cName)) { - // 循环调用:标记后作为链的根收尾,并把被调用方挂在下面 - TreeNode cn = makeNode(cName, false); - cn.state = "cycle"; - cn.children.append((name == startKey) ? downTree : makeNode(name, false)); - out.append(cn); + if (chainCount >= kMaxChains) { + // 链数超上限: 以 depthlimit 单节点收尾(非空链), 保证整体产出仍可展示 + TreeNode n = makeNode(cName, false); + n.state = "depthlimit"; + out.append(n); + ++chainCount; continue; } - QSet nextVis = vis; - nextVis.insert(name); - QList sub = self(self, cName, nextVis, depth + 1); + QList sub = self(self, cName, depth + 1); for (TreeNode &chain : sub) { // chain 是一条“到 cName 为止”的调用方链,把当前 name 挂到链末端(叶子)之下 TreeNode *leaf = &chain; @@ -630,12 +675,13 @@ CallChainAnalyzer::buildCallerForest(const QString &startName, const TreeNode &d } leaf->children.append((name == startKey) ? downTree : makeNode(name, false)); out.append(chain); + ++chainCount; } } return out; }; - return recurse(recurse, startKey, visited, 0); + return recurse(recurse, startKey, 0); } QSharedPointer @@ -749,11 +795,59 @@ QStringList CallChainAnalyzer::extractFunctionCalls(const QString &code) QString CallChainAnalyzer::stripComments(const QString &code) { - QString cleaned = code; - // 去除 /* ... */ 块注释(跨行、非贪婪) - cleaned.remove(QRegularExpression(R"(/\*.*?\*/)", - QRegularExpression::DotMatchesEverythingOption)); - // 去除 // 行注释(到行尾) - cleaned.remove(QRegularExpression(R"(//[^\n]*)")); + // 逐字符状态机(三态: 字符串字面量/行注释/块注释): + // 原正则方案把字符串里的 "//" "/*" 误当注释删除(如 s = "http://x" 会变成 + // s = "http:x"), 且删除式移除让注释两侧 token 粘连(a/*c*/b -> ab) + QString cleaned; + cleaned.reserve(code.size()); + bool inString = false; // 双引号字符串字面量 + bool inLineComment = false; // // 行注释 + bool inBlockComment = false; // /* */ 块注释 + const int n = code.size(); + for (int i = 0; i < n; ++i) { + const QChar ch = code.at(i); + const QChar next = (i + 1 < n) ? code.at(i + 1) : QChar(); + + if (inLineComment) { + if (ch == '\n') { + inLineComment = false; + cleaned += ch; // 保留换行维持行结构 + } + continue; + } + if (inBlockComment) { + if (ch == '*' && next == '/') { + inBlockComment = false; + ++i; + cleaned += ' '; // 注释整体替换为空格, 防止两侧 token 粘连 + } + continue; + } + if (inString) { + cleaned += ch; + if (ch == '\\' && !next.isNull()) { + cleaned += next; // 转义序列(\" \\)整体保留 + ++i; + } else if (ch == '"') { + inString = false; + } + continue; + } + // 普通代码区: 检测注释与字符串起点 + if (ch == '/' && next == '/') { + inLineComment = true; + ++i; + continue; + } + if (ch == '/' && next == '*') { + inBlockComment = true; + ++i; + continue; + } + if (ch == '"') { + inString = true; + } + cleaned += ch; + } return cleaned; } diff --git a/src/utils/callchainanalyzer.h b/src/utils/callchainanalyzer.h index 0ecf47d..b5e4944 100644 --- a/src/utils/callchainanalyzer.h +++ b/src/utils/callchainanalyzer.h @@ -29,7 +29,7 @@ public: struct TreeNode { - const FunctionInfo *info = nullptr; + QSharedPointer info; // 共享持有, 防止索引重建后裸指针悬垂 QString displayName; QString objectId; // 功能号(supported 节点尽量从名称中解析) QString state; // ok / notfound / cycle / depthlimit diff --git a/src/utils/configmanager.cpp b/src/utils/configmanager.cpp index c0090b2..d6ad036 100644 --- a/src/utils/configmanager.cpp +++ b/src/utils/configmanager.cpp @@ -2,11 +2,11 @@ #include "utils/Constants.h" #include "logmanager.h" #include +#include #include +#include #include -ConfigManager* ConfigManager::m_instance = nullptr; - ConfigManager::ConfigManager(QObject *parent) : QObject(parent) { } @@ -17,16 +17,81 @@ ConfigManager::~ConfigManager() ConfigManager* ConfigManager::instance() { - if (!m_instance) { - m_instance = new ConfigManager(); - } - return m_instance; + // 魔法静态(C++11 保证首次执行线程安全) + static ConfigManager* inst = new ConfigManager(); + return inst; } bool ConfigManager::isValidUtf8(const QByteArray& data) { QString test = QString::fromUtf8(data); - return test.toUtf8() == data; + if (test.toUtf8() != data) { + return false; + } + // 往返相等仍可能是 GBK 误读: GBK 扩展汉字次字节落在 0x80-0xBF 且首字节恰为 + // 双字节序列前导时, 按 UTF-8 解码整流后往返仍相等。区分启发式: 解码结果含 + // 拉丁/希腊/西里尔扩展字符(U+0080-U+024F/U+0370-U+04FF)且无 CJK → GBK + // 中文文件误读的典型特征; 真实 UTF-8 中文文本必含 CJK, 纯 ASCII 两者兼容不受影响 + bool hasExtended = false; + bool hasCJK = false; + for (const QChar ch : test) { + const ushort u = ch.unicode(); + if (u >= 0x4E00 && u <= 0x9FFF) { + hasCJK = true; + } else if ((u >= 0x0080 && u <= 0x024F) || (u >= 0x0370 && u <= 0x04FF)) { + hasExtended = true; + } + } + return !hasExtended || hasCJK; +} + +// 路径类配置键: 读写往返的分隔符转换必须作用于同一份键白名单, +// 否则白名单之外的键经 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); +} + +// 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); + if (sectionMatch.hasMatch()) { + currentSection = sectionMatch.captured(1).trimmed(); + // 同名 section 重复出现时合并(不清空), 防止手工维护 ini 追加段后前段键被清空 + if (!cache.contains(currentSection)) { + cache[currentSection] = QMap(); + } + continue; + } + + if (currentSection.isEmpty()) { + continue; + } + + QRegularExpressionMatch keyValueMatch = keyValueRegex.match(line); + if (keyValueMatch.hasMatch()) { + QString key = keyValueMatch.captured(1).trimmed(); + QString value = keyValueMatch.captured(2).trimmed(); + // 仅对路径白名单键做分隔符归一化, 与 saveConfig 的反向转换对称 + if (isPathLikeKey(currentSection, key)) { + value.replace("\\", "/"); + } + cache[currentSection][key] = value; + } + } + return cache; } bool ConfigManager::loadConfig(const QString& basePath) @@ -54,36 +119,13 @@ bool ConfigManager::loadConfig(const QString& basePath) } m_configCache.clear(); - - QRegularExpression sectionRegex("\\[(.+?)\\]"); - QRegularExpression keyValueRegex("^\\s*([^=\\s]+)\\s*=\\s*(.+)$", QRegularExpression::MultilineOption); - - QString currentSection; - QStringList lines = content.split('\n'); - - for (const QString& line : lines) { - QRegularExpressionMatch sectionMatch = sectionRegex.match(line); - if (sectionMatch.hasMatch()) { - currentSection = sectionMatch.captured(1).trimmed(); - m_configCache[currentSection] = QMap(); - continue; - } - - if (currentSection.isEmpty()) { - continue; - } - - QRegularExpressionMatch keyValueMatch = keyValueRegex.match(line); - if (keyValueMatch.hasMatch()) { - QString key = keyValueMatch.captured(1).trimmed(); - QString value = keyValueMatch.captured(2).trimmed(); - value.replace("\\", "/"); - m_configCache[currentSection][key] = value; - } - } - + // 解析逻辑与 saveConfig 共用 parseIniContent + m_configCache = parseIniContent(content); + // 记录加载时 mtime: saveConfig 据此探测加载后 ini 被外部手编, 防全量覆盖丢失外部修改 + m_loadedMtime = QFileInfo(m_configFilePath).lastModified(); + LogManager::instance()->log(QString("配置文件加载完成,共 %1 个section").arg(m_configCache.size())); - + return true; } @@ -113,10 +155,43 @@ bool ConfigManager::saveConfig() QByteArray data = readFile.readAll(); readFile.close(); isUtf8 = isValidUtf8(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; + 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 (merged > 0) { + LogManager::instance()->logWarning( + QString("config.ini 在加载后被外部修改, 已合并 %1 个新增 section/键再写回").arg(merged)); + } + m_loadedMtime = nowMtime; + } } } - QFile file(m_configFilePath); + // QSaveFile: 先写临时文件、成功后原子替换, 避免写入中途崩溃损坏 config.ini + // 注意: QSaveFile 不会自动创建缺失的父目录, config/ 目录必须先 mkpath + // (mkpath 对已存在目录幂等无害), 否则目录缺失时 open 失败静默丢失配置 + QDir().mkpath(QFileInfo(m_configFilePath).absolutePath()); + QSaveFile file(m_configFilePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { LogManager::instance()->logError(QString("无法打开配置文件进行写入: %1").arg(m_configFilePath)); return false; @@ -127,7 +202,8 @@ bool ConfigManager::saveConfig() content += QString("[%1]\n").arg(section); for (const QString& key : m_configCache[section].keys()) { QString value = m_configCache[section][key]; - if (section == "projectPath" && (key == "uft30" || key == "uf20" || key == "ufAcct20" || key == "createPath" || key == "uft30pub")) { + // 与 loadConfig 的正向转换对称: 仅路径白名单键换回反斜杠 + if (isPathLikeKey(section, key)) { value.replace("/", "\\"); } content += QString("%1=%2\n").arg(key).arg(value); @@ -142,7 +218,10 @@ bool ConfigManager::saveConfig() outData = content.toLocal8Bit(); } file.write(outData); - file.close(); + if (!file.commit()) { + LogManager::instance()->logError(QString("配置文件保存失败: %1").arg(file.errorString())); + return false; + } LogManager::instance()->log("配置文件保存完成"); diff --git a/src/utils/configmanager.h b/src/utils/configmanager.h index 48eff65..e9a6a2a 100644 --- a/src/utils/configmanager.h +++ b/src/utils/configmanager.h @@ -4,6 +4,7 @@ #include #include #include +#include class ConfigManager : public QObject { @@ -30,8 +31,9 @@ private: QString m_basePath; QString m_configFilePath; QMap> m_configCache; + // loadConfig 时的文件 mtime: saveConfig 据此探测加载后 ini 被外部手编, 防覆盖窗口 + QDateTime m_loadedMtime; - static ConfigManager* m_instance; }; #endif // CONFIGMANAGER_H diff --git a/src/utils/configreaderbase.cpp b/src/utils/configreaderbase.cpp index 15c146c..9e94a1b 100644 --- a/src/utils/configreaderbase.cpp +++ b/src/utils/configreaderbase.cpp @@ -87,7 +87,16 @@ bool ConfigReaderBase::doCheckFuncExists(const ConfigReaderTraits& traits, const QSet& cache, bool& cacheLoaded) { if (!cacheLoaded) { - doLoadCnameCache(traits, cache, cacheLoaded); + // 启动期首次存在性检查: 全量缓存重建需全表扫描+解析数万行 JSON(可达数秒), + // 同步执行会阻塞主线程拖慢启动。改为触发后台预热(不阻塞), 本次走单条查库兜底; + // 预热完成后后续检查自动切换为内存 contains 快路径。 + if (traits.warmupCache && traits.warmupCache()) { + // 预热已就绪: 从内存缓存派生 cname 集合并标记加载完成 + cache = traits.getAllCnames(); + cacheLoaded = true; + return cache.contains(funcName); + } + return traits.checkExists ? traits.checkExists(funcName) : false; } if (cache.isEmpty()) { // 内存缓存为空(数据尚未就绪/元数据更新进行中): 直接查库兜底, diff --git a/src/utils/configreaderbase.h b/src/utils/configreaderbase.h index 5e2c230..aae80bd 100644 --- a/src/utils/configreaderbase.h +++ b/src/utils/configreaderbase.h @@ -22,6 +22,9 @@ struct ConfigReaderTraits std::function hasConfig; // DataCache 是否已缓存该配置 std::function saveConfig; // DataCache 保存配置 std::function()> getAllCnames; // DataCache 读取全部 cname + // 触发功能缓存后台预热(不阻塞调用线程)。返回 true 表示内存缓存已就绪可立即派生 cname 集合; + // 返回 false 表示预热已在后台进行, 查询方应走单条查库兜底 + std::function warmupCache; // 单行直查(绕过 cname 内存缓存): 用于数据尚未就绪、内存缓存为空时兜底, // 避免把“缓存没准备好”误判为“功能不存在” std::function checkExists; diff --git a/src/utils/datacache.cpp b/src/utils/datacache.cpp index 23ba59f..2a58ef9 100644 --- a/src/utils/datacache.cpp +++ b/src/utils/datacache.cpp @@ -7,12 +7,12 @@ #include #include #include +#include #include #include #include #include - -DataCache* DataCache::m_instance = nullptr; +#include DataCache::DataCache(QObject *parent) : QObject(parent), m_dataLoaded(false) { @@ -29,15 +29,15 @@ DataCache::~DataCache() DataCache* DataCache::instance() { - if (!m_instance) { - m_instance = new DataCache(); - } - return m_instance; + // 魔法静态(C++11 保证首次执行线程安全); 裸指针永不析构, 避免静态析构顺序问题 + static DataCache* inst = new DataCache(); + return inst; } // 打开指定库连接(只读/读写均走此函数)。库文件不存在时仅创建 cache/ 目录并打开 SQLite, // 由 SQLite 自动生成空 DB 文件; _meta 表在 saveDataset 落库时按需创建, 此处不主动建表。 -QSqlDatabase DataCache::database(const QString& db) const +// createIfMissing=false 时(只读探测路径): 库文件不存在则不创建文件, 返回无效连接。 +QSqlDatabase DataCache::database(const QString& db, bool createIfMissing) const { const bool isUft3 = (db == "uft3"); QHash& conns = isUft3 ? m_uft3Db : m_uf20Db; @@ -57,6 +57,11 @@ QSqlDatabase DataCache::database(const QString& db) const QString path = isUft3 ? Constants::uft3CacheDb() : Constants::uf20CacheDb(); QFileInfo fi(path); + // 只读探测路径: 库文件不存在时静默返回无效连接, 不创建 cache 目录与空 DB 文件 + // (原实现所有调用方都会建库, 启动期"是否已缓存"探测会在首次启动生成 0KB 空库) + if (!createIfMissing && !QFile::exists(path)) { + return QSqlDatabase(); + } if (!fi.dir().exists() && !QDir().mkpath(fi.absolutePath())) { LogManager::instance()->logError(QString("数据缓存 - 创建缓存目录失败: %1").arg(fi.absolutePath())); return QSqlDatabase(); @@ -70,8 +75,16 @@ QSqlDatabase DataCache::database(const QString& db) const if (!conn.open()) { LogManager::instance()->logError(QString("数据缓存 - 打开 %1 缓存库失败: %2") .arg(db, conn.lastError().text())); + // 打开失败必须先置空局部副本再移除连接, 否则 QSqlDatabase 析构时报 + // "connection is still in use" 且连接残留在注册表内 + conn = QSqlDatabase(); + QSqlDatabase::removeDatabase(connName); return QSqlDatabase(); } + // 提升 SQLite 并发写入容忍度: 后台线程落库与主线程读库并发时, + // 默认约 5s 的 busy timeout 可能不够, 统一放宽到 30s + QSqlQuery pragmaQuery(conn); + pragmaQuery.exec("PRAGMA busy_timeout = 30000"); { QMutexLocker locker(&m_connMutex); conns.insert(self, conn); @@ -114,22 +127,22 @@ void DataCache::setDataLoaded(bool loaded) bool DataCache::datasetExists(const QString& db, const QString& table) const { - QSqlDatabase conn = database(db); + QSqlDatabase conn = database(db, /*createIfMissing=*/false); if (!conn.isOpen()) { return false; } + // 仅判断表内是否有行: COUNT(*) 需全表扫描 O(n), 接口表含大量 JSON 长文本, + // 大库时启动期逐行扫描明显拖慢首启; LIMIT 1 命中首行即返回, O(1)。 QSqlQuery query(conn); - query.prepare(QString("SELECT COUNT(*) FROM %1").arg(table)); - if (query.exec() && query.next()) { - return query.value(0).toInt() > 0; - } - return false; + query.prepare(QString("SELECT 1 FROM %1 LIMIT 1").arg(table)); + return query.exec() && query.next(); } -QMap DataCache::getAllRows(const QString& db, const QString& table) const +QMap DataCache::getAllRows(const QString& db, const QString& table, bool* ok) const { + if (ok) *ok = false; QMap result; - QSqlDatabase conn = database(db); + QSqlDatabase conn = database(db, /*createIfMissing=*/false); if (!conn.isOpen()) { return result; } @@ -139,13 +152,19 @@ QMap DataCache::getAllRows(const QString& db, const QString& t while (query.next()) { result[query.value(0).toString()] = query.value(1).toString(); } + if (ok) *ok = true; + } else { + // 原实现吞掉 exec 失败: 调用方拿到空表无法区分"确实无数据"与"查询失败", + // 功能缓存更会误标有效导致查询永远返回空, 必须落日志并通过 ok 上抛 + LogManager::instance()->logError(QString("数据缓存 - 查询 %1.%2 失败: %3") + .arg(db, table, query.lastError().text())); } return result; } QString DataCache::getRowData(const QString& db, const QString& table, const QString& key) const { - QSqlDatabase conn = database(db); + QSqlDatabase conn = database(db, /*createIfMissing=*/false); if (!conn.isOpen()) { return QString(); } @@ -160,7 +179,7 @@ QString DataCache::getRowData(const QString& db, const QString& table, const QSt int DataCache::rowCount(const QString& db, const QString& table) const { - QSqlDatabase conn = database(db); + QSqlDatabase conn = database(db, /*createIfMissing=*/false); if (!conn.isOpen()) { return 0; } @@ -172,8 +191,26 @@ int DataCache::rowCount(const QString& db, const QString& table) const return 0; } +double DataCache::getMetaSrcMtime(const QString& db, const QString& table) const +{ + QSqlDatabase conn = database(db, /*createIfMissing=*/false); + if (!conn.isOpen()) { + return -1; + } + // _meta 表不存在(库从未成功落库)时 exec 报错, 按无记录处理返回 -1 + QSqlQuery query(conn); + query.prepare("SELECT src_mtime FROM _meta WHERE dataset = :t"); + query.bindValue(":t", table); + if (query.exec() && query.next()) { + return query.value(0).toDouble(); + } + return -1; +} + // ---- 通用写入: 原子落库到双库对应表(对齐 Python store.save) ---- // 步骤: 建临时表 t__new -> 写入 -> DROP 旧表 -> RENAME 临时表 -> 更新 _meta +// 全程持有 m_writeGate 写闸门: clearCache 以 tryLock 检测在途落库任务, 在途时跳过连接重置, +// 避免本函数写入过程中连接被 clearCache 关闭(参见 clearCache) bool DataCache::saveDataset(const QString& db, const QString& table, const QMap& rows, double srcMtime) { @@ -182,6 +219,8 @@ bool DataCache::saveDataset(const QString& db, const QString& table, LogManager::instance()->logError(QString("数据缓存 - 写入 %1.%2 失败: 库未打开").arg(db, table)); return false; } + // 取闸门放在 database() 之后: m_connMutex 短暂持锁即释放, 与 clearCache 不构成死锁环 + QMutexLocker gate(&m_writeGate); QString tmpTable = "t_" + table + "_new"; @@ -191,8 +230,14 @@ bool DataCache::saveDataset(const QString& db, const QString& table, } QSqlQuery q(conn); - // 临时表 - q.exec(QString("DROP TABLE IF EXISTS %1").arg(tmpTable)); + // 先删旧表再建新表: 事务内 DROP 释放的页可被后续 INSERT 复用, 避免每次全量重建 + // 都在文件尾部追加一整份数据导致 DB 文件翻倍; 失败时整体 rollback, 旧表数据不丢。 + if (!q.exec(QString("DROP TABLE IF EXISTS %1").arg(table))) { + conn.rollback(); + LogManager::instance()->logError(QString("数据缓存 - 清理旧表 %1 失败: %2") + .arg(table, q.lastError().text())); + return false; + } if (!q.exec(QString("CREATE TABLE %1 (name TEXT PRIMARY KEY, data TEXT)").arg(tmpTable))) { conn.rollback(); LogManager::instance()->logError(QString("数据缓存 - 建临时表失败: %1").arg(q.lastError().text())); @@ -211,8 +256,7 @@ bool DataCache::saveDataset(const QString& db, const QString& table, return false; } } - // 原子替换 - q.exec(QString("DROP TABLE IF EXISTS %1").arg(table)); + // 原子替换(旧表已在事务开头删除, 此处仅重命名新表) if (!q.exec(QString("ALTER TABLE %1 RENAME TO %2").arg(tmpTable, table))) { conn.rollback(); LogManager::instance()->logError(QString("数据缓存 - 重命名 %1 失败: %2") @@ -238,9 +282,26 @@ bool DataCache::saveDataset(const QString& db, const QString& table, } LogManager::instance()->logInfo(QString("数据缓存 - 已写入 %1.%2 共 %3 行") .arg(db, table).arg(rows.size())); - // 接口表落库后使功能查询内存缓存失效, 下次查询自动重建 - if (table == Constants::Cache::Uf20InterfaceTable) m_uf20FuncCacheValid = false; - if (table == Constants::Cache::Uft3InterfaceTable) m_uft3FuncCacheValid = false; + // 修复前的历史库文件已膨胀(空闲页约占一半): 检测到空闲页过大时执行一次 VACUUM 回收磁盘。 + // VACUUM 不能在事务内执行, 故放在 commit 成功之后。 + QSqlQuery v(conn); + if (v.exec("PRAGMA freelist_count") && v.next()) { + // 空闲页字节数 = 空闲页数 * 实际 page_size(建库时可自定义, 不应硬编码 4096) + qlonglong pageSize = 4096; + 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) { + LogManager::instance()->logInfo(QString("数据缓存 - %1 空闲页过大(%2 MB), 执行 VACUUM 回收磁盘空间...") + .arg(db).arg(freeBytes / (1024 * 1024))); + v.exec("VACUUM"); + } + } + // 注意: 此处不写功能查询内存缓存失效标志(m_*FuncCacheValid)——本函数运行在 + // QtConcurrent 后台线程, 与主线程 cachedFunctions() 无锁读写同一 bool 构成数据竞争; + // 缓存重建统一由主线程在落库任务完成后调用 refreshFunctionCache() 收口。 return true; } @@ -288,6 +349,123 @@ namespace { } return list; } + + // 单条功能在指定 field 上是否命中关键词(公共实现, UF20/UFT3 两库共用): + // exactMatch 时整串相等(区分大小写), 否则子串包含(不区分大小写); + // field 为空/未识别时三字段任一命中即算命中 + bool matchFunctionField(const FunctionInfo& info, const QString& keyword, + const QString& field, bool exactMatch) + { + const Qt::CaseSensitivity cs = exactMatch ? Qt::CaseSensitive : Qt::CaseInsensitive; + if (field == "cname") { + return exactMatch ? (info.cname == keyword) : info.cname.contains(keyword, cs); + } else if (field == "function_no") { + return exactMatch ? (info.functionNo == keyword) : info.functionNo.contains(keyword, cs); + } else if (field == "eName") { + return exactMatch ? (info.eName == keyword) : info.eName.contains(keyword, cs); + } + return info.cname.contains(keyword, cs) || + info.eName.contains(keyword, cs) || + info.functionNo.contains(keyword, cs); + } + + // 多关键词过滤(公共实现, UF20/UFT3 两库共用): + // 任一关键词命中即收录(OR 并集), 结果按 cname+functionNo 去重(同一功能只显示一次); + // keywordsWithoutMatch 出参: 一个功能都没命中的关键词个数(去空/去重后计) + QList filterFunctionInfos(const QList& all, + const QStringList& keywords, + const QString& field, + bool exactMatch, + int* keywordsWithoutMatch = nullptr) + { + if (keywordsWithoutMatch) *keywordsWithoutMatch = 0; + + // 关键词预处理: trim 去空 + 去重(同一关键词重复输入只算一个) + QStringList kws; + QSet seenKw; + for (const QString& raw : keywords) { + const QString kw = raw.trimmed(); + if (!kw.isEmpty() && !seenKw.contains(kw)) { + seenKw.insert(kw); + kws.append(kw); + } + } + if (kws.isEmpty()) { + return {}; + } + + QList result; + QSet seenRows; + QList kwHit(kws.size(), false); + for (const FunctionInfo& info : all) { + bool hit = false; + for (int i = 0; i < kws.size(); ++i) { + if (matchFunctionField(info, kws.at(i), field, exactMatch)) { + kwHit[i] = true; + hit = true; + } + } + if (hit) { + const QString rowKey = info.cname + '|' + info.functionNo; + if (!seenRows.contains(rowKey)) { + seenRows.insert(rowKey); + result.append(info); + } + } + } + if (keywordsWithoutMatch) { + for (bool h : kwHit) { + if (!h) ++(*keywordsWithoutMatch); + } + } + return result; + } +} + +// 构建指定库的全量 FunctionInfo 列表(全表扫描+JSON 解析)。 +// 可在任意线程调用: getAllRows 按线程建立专用连接, 与主线程查询互不阻塞。 +QList DataCache::buildFunctionCache(const QString& db, bool* ok) const +{ + if (ok) *ok = false; + const bool isUf20 = (db == "uf20"); + QList cache; + if (isUf20) { + // UF20 功能号全部落在 uf2_interface 一张表 + bool rowsOk = false; + QMap rows = getAllRows(db, Constants::Cache::Uf20InterfaceTable, &rowsOk); + if (!rowsOk) { + return cache; // 查询失败返回空列表, 避免半截缓存被误标有效 + } + for (auto it = rows.begin(); it != rows.end(); ++it) { + cache.append(parseInterfaceData(it.value())); + } + } else { + // UFT3 功能号分散在 uft3_interface (普通接口) 与 uft3_trans_code (转码) 两张表, + // 必须合并才能覆盖全部 LS 转码功能号 (如 LS_证券周边_客户资金余额获取)。 + QStringList tables; + tables << Constants::Cache::Uft3InterfaceTable + << Constants::Cache::TblUft3TransCode; + for (const QString& t : tables) { + // uft3_trans_code 仅在交易码数据非空时由 saveDataset 创建(saveRowsFromJson + // 对空数据不落库), 工程无交易码时缺表是合法状态, 先探测避免缓存构建每次失败 + if (!datasetExists(db, t)) { + LogManager::instance()->logInfo( + QString("数据缓存 - %1 表不存在(无交易码数据?), 跳过该表").arg(t)); + continue; + } + bool rowsOk = false; + QMap rows = getAllRows(db, t, &rowsOk); + if (!rowsOk) { + if (ok) *ok = false; // 显式置失败, 避免半截缓存被调用方误标有效 + return cache; + } + for (auto it = rows.begin(); it != rows.end(); ++it) { + cache.append(parseInterfaceData(it.value())); + } + } + } + if (ok) *ok = true; + return cache; } // 懒加载并缓存指定库的解析后 FunctionInfo 列表。 @@ -302,27 +480,43 @@ const QList& DataCache::cachedFunctions(const QString& db) const return cache; } - cache.clear(); + // 后台预热在途或已完成未消费: 等待/直接接管其结果, 避免主线程重复全表扫描。 + // 注意: 预热任务完成后 isRunning() 变 false, 若只检查 isRunning 会漏掉已完成的 + // 预热结果导致主线程再次全量重建, 因此以"是否还有未消费结果"为接管条件。 + // (预热任务只构建局部列表不写共享成员, 读写仍全部在主线程, 无并发风险) + QFuture>& warmup = isUf20 ? m_uf20WarmupFuture : m_uft3WarmupFuture; + if (warmup.isRunning()) { + // 有界等待: 预热为全表扫描+JSON 解析, 大库可达数秒, 无界 waitForFinished 会冻结 UI。 + // 轮询间隙处理输入以外的事件(绘制/定时器), 保持界面响应; 超限后转主线程同步重建。 + QElapsedTimer waitTimer; + waitTimer.start(); + while (warmup.isRunning() && waitTimer.elapsed() < 5000) { + // Qt 6.6 的 QFuture 无带超时参数的 waitForFinished, 用 processEvents 等待间隙让线程推进 + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 50); + } + if (warmup.isRunning()) { + LogManager::instance()->logWarning( + QString("数据缓存 - %1 功能预热未在限期(5s)内完成, 转为主线程同步重建").arg(db)); + } + } + if (warmup.isValid() && warmup.resultCount() > 0) { + cache = warmup.result(); + valid = true; + LogManager::instance()->logInfo(QString("数据缓存 - 接管 %1 后台预热功能缓存 %2 条") + .arg(db).arg(cache.size())); + return cache; + } + QElapsedTimer timer; timer.start(); - if (isUf20) { - // UF20 功能号全部落在 uf2_interface 一张表 - QMap rows = getAllRows(db, Constants::Cache::Uf20InterfaceTable); - for (auto it = rows.begin(); it != rows.end(); ++it) { - cache.append(parseInterfaceData(it.value())); - } - } else { - // UFT3 功能号分散在 uft3_interface (普通接口) 与 uft3_trans_code (转码) 两张表, - // 必须合并才能覆盖全部 LS 转码功能号 (如 LS_证券周边_客户资金余额获取)。 - QStringList tables; - tables << Constants::Cache::Uft3InterfaceTable - << Constants::Cache::TblUft3TransCode; - for (const QString& t : tables) { - QMap rows = getAllRows(db, t); - for (auto it = rows.begin(); it != rows.end(); ++it) { - cache.append(parseInterfaceData(it.value())); - } - } + bool buildOk = false; + cache = buildFunctionCache(db, &buildOk); + if (!buildOk) { + // 查询失败(库未打开/SQL 出错)时不置 valid: 下次查询重试重建, + // 避免空缓存被标有效后所有功能查询永远返回空结果 + LogManager::instance()->logError( + QString("数据缓存 - 重建 %1 功能缓存失败(查询出错), 本次查询返回空结果, 稍后自动重试").arg(db)); + return cache; } valid = true; LogManager::instance()->logInfo(QString("数据缓存 - 重建 %1 功能缓存 %2 条,耗时: %3ms") @@ -330,14 +524,43 @@ const QList& DataCache::cachedFunctions(const QString& db) const return cache; } +bool DataCache::warmupFunctionCacheAsync(const QString& db) +{ + const bool isUf20 = (db == "uf20"); + bool& valid = isUf20 ? m_uf20FuncCacheValid : m_uft3FuncCacheValid; + QFuture>& warmup = isUf20 ? m_uf20WarmupFuture : m_uft3WarmupFuture; + + if (valid) { + return true; // 已就绪 + } + if (warmup.isRunning()) { + return false; // 已在后台预热中 + } + + warmup = QtConcurrent::run([this, db]() -> QList { + QElapsedTimer timer; + timer.start(); + QList list = buildFunctionCache(db); + LogManager::instance()->logInfo(QString("数据缓存 - 后台预热 %1 功能缓存 %2 条,耗时: %3ms") + .arg(db).arg(list.size()).arg(timer.elapsed())); + return list; + }); + return false; +} + void DataCache::refreshFunctionCache() { + // 元数据已变更: 覆盖在途旧预热 future(旧任务只构建局部列表不写共享成员, 无并发风险, + // 旧结果因 future 被覆盖无人接管自然丢弃, 不会被 cachedFunctions 误接管) + m_uf20WarmupFuture = QFuture>(); + m_uft3WarmupFuture = QFuture>(); + m_uf20FuncCacheValid = false; m_uft3FuncCacheValid = false; - // 立即预热, 使后续查询走内存缓存 - cachedFunctions("uf20"); - cachedFunctions("uft3"); - LogManager::instance()->logInfo("数据缓存 - 功能查询缓存已重建"); + // 触发后台重建, 不阻塞主线程; 后续查询在预热完成前按原全表路径兜底 + warmupFunctionCacheAsync("uf20"); + warmupFunctionCacheAsync("uft3"); + LogManager::instance()->logInfo("数据缓存 - 功能查询缓存已触发后台重建"); } bool DataCache::checkUF20CnameExists(const QString& cname) @@ -367,29 +590,14 @@ QSet DataCache::getAllUF20Cnames() QList DataCache::searchUF20Functions(const QString& keyword, const QString& field, bool exactMatch) { - QList result; - QString kw = keyword.trimmed(); - Qt::CaseSensitivity cs = exactMatch ? Qt::CaseSensitive : Qt::CaseInsensitive; + // 公共过滤实现与 UFT3 共用(见匿名命名空间 filterFunctionInfos) + return filterFunctionInfos(cachedFunctions("uf20"), QStringList{ keyword }, field, exactMatch); +} - const QList& all = cachedFunctions("uf20"); - for (const FunctionInfo& info : all) { - bool hit = false; - if (field == "cname") { - hit = exactMatch ? (info.cname == kw) : info.cname.contains(kw, cs); - } else if (field == "function_no") { - hit = exactMatch ? (info.functionNo == kw) : info.functionNo.contains(kw, cs); - } else if (field == "eName") { - hit = exactMatch ? (info.eName == kw) : info.eName.contains(kw, cs); - } else { - hit = (info.cname.contains(kw, cs) || - info.eName.contains(kw, cs) || - info.functionNo.contains(kw, cs)); - } - if (hit) { - result.append(info); - } - } - return result; +QList DataCache::searchUF20Functions(const QStringList& keywords, const QString& field, + bool exactMatch, int* keywordsWithoutMatch) +{ + return filterFunctionInfos(cachedFunctions("uf20"), keywords, field, exactMatch, keywordsWithoutMatch); } bool DataCache::checkUFT3CnameExists(const QString& cname) @@ -419,48 +627,59 @@ QSet DataCache::getAllUFT3Cnames() QList DataCache::searchUFT3Functions(const QString& keyword, const QString& field, bool exactMatch) { - QList result; - QString kw = keyword.trimmed(); - Qt::CaseSensitivity cs = exactMatch ? Qt::CaseSensitive : Qt::CaseInsensitive; + // 公共过滤实现与 UF20 共用(见匿名命名空间 filterFunctionInfos) + return filterFunctionInfos(cachedFunctions("uft3"), QStringList{ keyword }, field, exactMatch); +} - const QList& all = cachedFunctions("uft3"); - for (const FunctionInfo& info : all) { - bool hit = false; - if (field == "cname") { - hit = exactMatch ? (info.cname == kw) : info.cname.contains(kw, cs); - } else if (field == "function_no") { - hit = exactMatch ? (info.functionNo == kw) : info.functionNo.contains(kw, cs); - } else if (field == "eName") { - hit = exactMatch ? (info.eName == kw) : info.eName.contains(kw, cs); - } else { - hit = (info.cname.contains(kw, cs) || - info.eName.contains(kw, cs) || - info.functionNo.contains(kw, cs)); - } - if (hit) { - result.append(info); - } - } - return result; +QList DataCache::searchUFT3Functions(const QStringList& keywords, const QString& field, + bool exactMatch, int* keywordsWithoutMatch) +{ + return filterFunctionInfos(cachedFunctions("uft3"), keywords, field, exactMatch, keywordsWithoutMatch); } void DataCache::clearCache() { - // 缓存库由 C++ 元数据解析直接落库维护。此处仅重置内存状态并关闭连接, 不删除库文件。 + // 等待可能在途的后台预热任务完成, 避免关闭其正在使用的数据库连接 + 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(); - QSqlDatabase::removeDatabase(it.value().connectionName()); - } - for (auto it = m_uft3Db.begin(); it != m_uft3Db.end(); ++it) { - if (it.value().isOpen()) it.value().close(); - QSqlDatabase::removeDatabase(it.value().connectionName()); + 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(); } diff --git a/src/utils/datacache.h b/src/utils/datacache.h index 01f46ed..3e38841 100644 --- a/src/utils/datacache.h +++ b/src/utils/datacache.h @@ -11,6 +11,7 @@ #include #include #include +#include struct FunctionInfo { QString cname; @@ -39,12 +40,16 @@ public: // 物理表结构统一为: (name TEXT PRIMARY KEY, data TEXT), 对齐 Python src/store/schema.py // 接口表是否存在且非空(用于“是否已缓存”判定) bool datasetExists(const QString& db, const QString& table) const; - // 读取整张键值表: name -> data(JSON 字符串) - QMap getAllRows(const QString& db, const QString& table) const; + // 读取整张键值表: 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 @@ -63,14 +68,25 @@ public: bool checkUF20CnameExists(const QString& cname); QSet getAllUF20Cnames(); QList searchUF20Functions(const QString& keyword, const QString& field = "", bool exactMatch = false); + // 多关键词重载: 任一关键词命中即收录(OR 并集), 结果按 cname+functionNo 去重; + // keywordsWithoutMatch 出参: 一个功能都没命中的关键词个数(页面用于提示部分未命中) + QList searchUF20Functions(const QStringList& keywords, const QString& field = "", + bool exactMatch = false, int* keywordsWithoutMatch = nullptr); bool checkUFT3CnameExists(const QString& cname); QSet getAllUFT3Cnames(); QList searchUFT3Functions(const QString& keyword, const QString& field = "", bool exactMatch = false); + QList searchUFT3Functions(const QStringList& keywords, const QString& field = "", + bool exactMatch = false, int* keywordsWithoutMatch = nullptr); // 重新构建功能查询内存缓存(元数据落库后调用, 使后续查询走缓存而非全表扫描) void refreshFunctionCache(); + // 触发指定库功能缓存的后台预热(不阻塞调用线程)。 + // 返回 true 表示内存缓存已就绪可直接使用; false 表示预热已在后台进行, + // 查询方应走单条查库兜底(用于启动期存在性检查不阻塞主线程)。 + bool warmupFunctionCacheAsync(const QString& db); + void clearCache(); private: @@ -80,24 +96,41 @@ private: // 取指定库连接。QSqlDatabase 连接只能在创建它的线程中使用, // 因此按线程分别建立并缓存连接(元数据更新在 QtConcurrent 后台线程执行, // 而查询在主线程执行, 共用同一连接会导致写入静默失败)。 - QSqlDatabase database(const QString& db) const; + // createIfMissing=false 时(只读探测路径): 库文件不存在则静默返回无效连接, + // 不创建 cache 目录与空 DB 文件(避免启动期探测生成 0KB 空库) + QSqlDatabase database(const QString& db, bool createIfMissing = true) const; // 懒加载并缓存指定库的解析后 FunctionInfo 列表(避免每次查询全表扫描+重解析) const QList& cachedFunctions(const QString& db) const; - static DataCache* m_instance; + // 构建指定库的全量 FunctionInfo 列表(全表扫描+JSON 解析)。 + // 可在任意线程调用: getAllRows 按线程建立专用连接, 与主线程查询互不阻塞。 + // 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; bool m_dataLoaded; // 功能查询内存缓存(UF20/UFT3 各一份, 落库或清缓存后失效重建) + // 就地初始化 false: 未初始化的堆值可能恰好非 0, 会让缓存"假有效"导致查询永远返回空 mutable QList m_uf20FuncCache; mutable QList m_uft3FuncCache; - mutable bool m_uf20FuncCacheValid; - mutable bool m_uft3FuncCacheValid; + mutable bool m_uf20FuncCacheValid = false; + mutable bool m_uft3FuncCacheValid = false; + + // 功能缓存的在途后台预热任务(结果暂存 QFuture, 由主线程 cachedFunctions 接管, + // 共享成员的读写仍全部发生在主线程, 无需额外加锁) + mutable QFuture> m_uf20WarmupFuture; + mutable QFuture> m_uft3WarmupFuture; }; #endif diff --git a/src/utils/logmanager.cpp b/src/utils/logmanager.cpp index d719212..6027d31 100644 --- a/src/utils/logmanager.cpp +++ b/src/utils/logmanager.cpp @@ -15,8 +15,6 @@ #include #endif -LogManager* LogManager::m_instance = nullptr; - LogManager::LogManager(QObject *parent) : QObject(parent) { // 日志目录固定为 exe 所在目录下的 logs,尽早确定, @@ -34,10 +32,10 @@ LogManager::~LogManager() LogManager* LogManager::instance() { - if (!m_instance) { - m_instance = new LogManager(); - } - return m_instance; + // 魔法静态(C++11 保证首次执行线程安全); 裸指针永不析构, + // 避免进程退出时其他静态对象析构期间写日志访问已析构实例 + static LogManager* inst = new LogManager(); + return inst; } void LogManager::initLogFile() @@ -79,6 +77,7 @@ void LogManager::ensureFileOpen() // 跨天运行时,当前打开的文件不属于今天 -> 关闭旧文件,切换到今天的文件 if (m_logFile.isOpen() && m_logFilePath != filePath) { m_logFile.close(); + m_fileBytes = 0; } if (!m_logFile.isOpen()) { @@ -86,6 +85,9 @@ void LogManager::ensureFileOpen() m_logFile.setFileName(filePath); if (!m_logFile.open(QIODevice::Append | QIODevice::Text)) { qDebug() << "无法打开日志文件:" << filePath; + } else { + // 追加模式打开: 以文件实际大小初始化字节缓存, 后续轮转判断不再逐条 stat + m_fileBytes = m_logFile.size(); } } } @@ -96,7 +98,40 @@ void LogManager::rotateFile() m_logFile.close(); } - // 现有轮转文件整体后移一位:.N -> .N+1,超出 maxFiles 的丢弃 + // 当前文件 -> .1: 先归档当前文件, 成功后才移历史链—— + // 若先移历史后 rename 失败(文件被占用), 历史日志已被白白消耗一代而轮转从未成功 + QString first = m_logFilePath + ".1"; + if (QFile::exists(first)) { + QFile::remove(first); + } + bool rotated = true; + if (QFile::exists(m_logFilePath)) { + rotated = QFile::rename(m_logFilePath, first); + if (!rotated) { + // .1 改名失败(多为文件被其他进程占用, 如用户用编辑器打开了当前日志): + // 尝试毫秒级时间戳备选名, 避开被占用的目标名 + const QString alt = QString("%1.%2").arg(m_logFilePath) + .arg(QDateTime::currentMSecsSinceEpoch()); + rotated = QFile::rename(m_logFilePath, alt); + } + } + if (!rotated) { + // 轮转彻底失败: 保持原文件继续 Append(记录量会临时超出 maxSize)。 + // m_fileBytes 置 0(而非 reopen 时按实际大小重置)以断开"每条写入都重试轮转" + // 的 close/reopen 循环, 待再次写满 maxSize 时重试; 仅告警一次避免刷屏 + if (!m_rotateWarned) { + m_rotateWarned = true; + qWarning() << "日志轮转失败(文件可能被占用), 暂继续写入原文件:" << m_logFilePath; + } + m_logFile.setFileName(m_logFilePath); + if (m_logFile.open(QIODevice::Append | QIODevice::Text)) { + m_fileBytes = 0; + } + return; + } + m_rotateWarned = false; + + // 当前文件已成功归档, 此时再整体后移历史链: .N -> .N+1,超出 maxFiles 的丢弃 for (int i = m_maxFiles - 1; i >= 1; --i) { QString dst = QString("%1.%2").arg(m_logFilePath).arg(i + 1); if (QFile::exists(dst)) { @@ -107,14 +142,6 @@ void LogManager::rotateFile() QFile::rename(src, dst); } } - // 当前文件 -> .1 - QString first = m_logFilePath + ".1"; - if (QFile::exists(first)) { - QFile::remove(first); - } - if (QFile::exists(m_logFilePath)) { - QFile::rename(m_logFilePath, first); - } cleanupByAge(); ensureFileOpen(); @@ -159,9 +186,15 @@ void LogManager::initLogConfig() } // 3) 轮转与清理 + // maxSize 解析失败(-1)不允许当作"不限制", 回退默认 10MB; 显式配置 0 仍表示关闭轮转 + bool sizeInvalid = false; m_maxSizeBytes = parseSizeToBytes(cc.value("log/maxSize", "10MB").toString()); - m_maxFiles = cc.value("log/maxFiles", 100).toInt(); - m_maxAgeDays = cc.value("log/maxAge", 30).toInt(); + if (m_maxSizeBytes < 0) { + m_maxSizeBytes = 10LL * 1024 * 1024; + sizeInvalid = true; + } + m_maxFiles = qMax(1, cc.value("log/maxFiles", 100).toInt()); // 轮转至少保留 1 个历史文件 + m_maxAgeDays = qMax(0, cc.value("log/maxAge", 30).toInt()); // 清理天数不允许为负 // 4) 控制台 / 文件 / 调用堆栈 / 格式 / 时间格式 m_toConsole = cc.value("log/console", true).toBool(); @@ -173,13 +206,20 @@ void LogManager::initLogConfig() if (m_timeFormat.isEmpty()) { m_timeFormat = "yyyy-MM-dd HH:mm:ss.zzz"; } + + // 告警延迟到全部字段初始化完成后: writeLog 依赖上述配置, 半初始化状态下写日志会用到无效值 + if (sizeInvalid) { + logWarning("log/maxSize 配置无效, 已回退默认值 10MB"); + } } qint64 LogManager::parseSizeToBytes(const QString& s) { + // 解析失败返回 -1 与"显式配置 0 = 关闭轮转"区分开, + // 否则非法配置(如 "abc")会被上层当作不限大小, 日志无限膨胀 QString str = s.trimmed().toUpper(); if (str.isEmpty()) { - return 0; + return -1; } int idx = 0; while (idx < str.size() && (str.at(idx).isDigit() || str.at(idx) == QLatin1Char('.'))) { @@ -188,7 +228,7 @@ qint64 LogManager::parseSizeToBytes(const QString& s) bool ok = false; double val = str.left(idx).toDouble(&ok); if (!ok) { - val = 0; + return -1; } QString unit = str.mid(idx).trimmed(); qint64 mult = 1; @@ -336,13 +376,18 @@ void LogManager::writeLog(LogLevel level, const QString& message) if (m_toFile) { ensureFileOpen(); - if (m_maxSizeBytes > 0 && QFileInfo(m_logFilePath).size() + logLine.size() > m_maxSizeBytes) { + // 轮转阈值按字节比较: logLine.size() 是 UTF-16 字符数, 中文行(1 字符占 3 个 UTF-8 字节) + // 会低估实际写入量, 导致文件超出 maxSize 才轮转。 + // 用 m_fileBytes 缓存代替 QFileInfo(...).size(), 避免每条日志都 stat 一次磁盘 + const QByteArray utf8Line = logLine.toUtf8(); + if (m_maxSizeBytes > 0 && m_fileBytes + utf8Line.size() > m_maxSizeBytes) { rotateFile(); } if (m_logFile.isOpen()) { QTextStream stream(&m_logFile); stream << logLine; stream.flush(); + m_fileBytes += utf8Line.size(); // 同步维护字节缓存 } } diff --git a/src/utils/logmanager.h b/src/utils/logmanager.h index 231f6e3..0064e49 100644 --- a/src/utils/logmanager.h +++ b/src/utils/logmanager.h @@ -48,11 +48,10 @@ private: void rotateFile(); // 超过 maxSize 时按 maxFiles 轮转 void cleanupByAge(); // 删除超过 maxAge 天的历史日志 QString captureStackTrace(); // 调用堆栈(caller=true 时) - static qint64 parseSizeToBytes(const QString& s); // "10MB" -> 字节 + static qint64 parseSizeToBytes(const QString& s); // "10MB" -> 字节; 解析失败返回 -1 static QString translateTimeFormat(const QString& fmt); // Go 布局 -> Qt 格式 QString resolveLogDir() const; // 解析日志目录(空 -> 当前目录) - static LogManager* m_instance; QFile m_logFile; QMutex m_mutex; QString m_logFilePath; @@ -62,9 +61,11 @@ private: QString m_logDir; // 日志目录(空 -> 当前目录) QString m_baseName = "change_code.log"; // 日志基础文件名(实际文件名会带日期前缀 YYYYMMDD-) qint64 m_maxSizeBytes = 10LL * 1024 * 1024; // 单文件大小上限(字节),0=不限制 + qint64 m_fileBytes = 0; // 当前日志文件已写字节数(缓存, 免去每条日志 stat 一次) int m_maxFiles = 100; // 轮转保留文件数 int m_maxAgeDays = 30; // 保留天数,0=不限制 bool m_caller = false; // 是否附加调用堆栈 + bool m_rotateWarned = 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 5727d92..28e04bd 100644 --- a/src/utils/uf2configreader.cpp +++ b/src/utils/uf2configreader.cpp @@ -24,6 +24,9 @@ const ConfigReaderTraits& UF2ConfigReader::traits() traits.getAllCnames = [] { return DataCache::instance()->getAllUF20Cnames(); }; + traits.warmupCache = [] { + return DataCache::instance()->warmupFunctionCacheAsync("uf20"); + }; traits.checkExists = [](const QString& cname) { return DataCache::instance()->checkUF20CnameExists(cname); }; diff --git a/src/utils/uft3configreader.cpp b/src/utils/uft3configreader.cpp index 4e9c1d1..21238a5 100644 --- a/src/utils/uft3configreader.cpp +++ b/src/utils/uft3configreader.cpp @@ -28,6 +28,9 @@ const ConfigReaderTraits& UFT3ConfigReader::traits() traits.getAllCnames = [] { return DataCache::instance()->getAllUFT3Cnames(); }; + traits.warmupCache = [] { + return DataCache::instance()->warmupFunctionCacheAsync("uft3"); + }; traits.checkExists = [](const QString& cname) { DataCache *cache = DataCache::instance(); // 与 getAllUFT3Cnames 覆盖范围保持一致: interface + trans_code 两张表 diff --git a/src/utils/version.cpp b/src/utils/version.cpp index 9d7cf77..4fd3358 100644 --- a/src/utils/version.cpp +++ b/src/utils/version.cpp @@ -1,6 +1,5 @@ #include "version.h" #include "Constants.h" -#include namespace Version { @@ -11,7 +10,9 @@ namespace Version { QString getBuildDateTime() { - return QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss"); + // 编译期宏: __DATE__ = "Sep 10 2026", __TIME__ = "hh:mm:ss", 记录真实编译时刻 + // (原实现返回 currentDateTime(), "关于"页会显示运行时刻而非构建时刻) + return QString::fromLatin1(__DATE__) + QLatin1Char(' ') + QString::fromLatin1(__TIME__); } int compare(const QString &v1, const QString &v2) diff --git a/src/utils/versionchecker.cpp b/src/utils/versionchecker.cpp index 52e683b..058bfd6 100644 --- a/src/utils/versionchecker.cpp +++ b/src/utils/versionchecker.cpp @@ -1,8 +1,10 @@ #include "versionchecker.h" #include "version.h" +#include "logmanager.h" #include #include #include +#include #include VersionChecker::VersionChecker(QObject *parent) @@ -13,11 +15,20 @@ VersionChecker::VersionChecker(QObject *parent) void VersionChecker::check(const QString &updateJsonUrl, const QString ¤tVersion) { + if (m_checking) { + // 上一次请求在途(超时上限 15s), 直接忽略本次点击 + LogManager::instance()->logInfo("版本检查: 上一次检查仍在进行中, 忽略本次请求"); + return; + } + m_checking = true; m_currentVersion = currentVersion; QNetworkRequest request{QUrl(updateJsonUrl)}; request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + // 必须设置超时: 默认为 0(不限时), 服务器半开/挂起时 finished 永不发射, + // "检查更新"将永久无响应且 reply 持续泄漏 + request.setTransferTimeout(15000); QNetworkReply *reply = m_manager->get(request); connect(reply, &QNetworkReply::finished, this, [this, reply]() { onReplyFinished(reply); @@ -26,6 +37,7 @@ void VersionChecker::check(const QString &updateJsonUrl, const QString ¤tV void VersionChecker::onReplyFinished(QNetworkReply *reply) { + m_checking = false; reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { @@ -44,7 +56,10 @@ void VersionChecker::onReplyFinished(QNetworkReply *reply) QJsonObject obj = doc.object(); - // 遍历所有版本键(如 "V1.0.0"、"V1.1.0"),找出最新版本 + // 遍历所有版本键(如 "V1.0.0"、"V1.1.0"),找出最新版本。 + // 仅接受纯数字版本号: 文件中混入的杂质键(如注释性字段)若被当成版本号, + // 其值非版本对象时会弹出错误的升级提示 + static const QRegularExpression verRe("^\\d+(\\.\\d+){0,3}$"); QString latestKey; QString latestVersion; // 去掉 V 前缀后的纯版本号,如 "1.0.0" for (auto it = obj.begin(); it != obj.end(); ++it) { @@ -54,6 +69,10 @@ void VersionChecker::onReplyFinished(QNetworkReply *reply) if (ver.startsWith('V') || ver.startsWith('v')) ver = ver.mid(1); + if (!verRe.match(ver).hasMatch()) { + continue; + } + if (latestVersion.isEmpty() || Version::compare(ver, latestVersion) > 0) { latestVersion = ver; latestKey = key; diff --git a/src/utils/versionchecker.h b/src/utils/versionchecker.h index 9913f5e..8e0eaf3 100644 --- a/src/utils/versionchecker.h +++ b/src/utils/versionchecker.h @@ -24,6 +24,9 @@ private slots: private: QNetworkAccessManager *m_manager; QString m_currentVersion; + // 防重入: 在途时拒绝新请求, 否则旧回复到达时 m_currentVersion 已被新请求改写, + // 版本比较时序错乱且可能并发两个升级弹窗 + bool m_checking = false; }; #endif // VERSIONCHECKER_H