完成 0.2.0 预发布收口与中文安装器

This commit is contained in:
2026-09-09 01:20:45 +08:00
parent 90262aaf6d
commit 1f5e26c361
30 changed files with 1826 additions and 121 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(QtDesktopPet VERSION 0.1.0 LANGUAGES CXX) project(QtDesktopPet VERSION 0.2.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -89,6 +89,8 @@ qt_add_executable(QtDesktopPet
src/weather/WeatherConfig.h src/weather/WeatherConfig.h
src/weather/WeatherManager.h src/weather/WeatherManager.h
src/weather/WeatherManager.cpp src/weather/WeatherManager.cpp
src/weather/WeatherLocationResolver.h
src/weather/WeatherLocationResolver.cpp
src/weather/WeatherParser.h src/weather/WeatherParser.h
src/weather/WeatherParser.cpp src/weather/WeatherParser.cpp
src/weather/WeatherStore.h src/weather/WeatherStore.h
+12
View File
@@ -382,6 +382,7 @@ dist/QtDesktopPet-<version>-windows-x64.zip
```text ```text
QtDesktopPet.exe QtDesktopPet.exe
Qt runtime Qt runtime
MSVC x64 runtime(MSVC 构建时采用应用本地部署)
resources/characters/ resources/characters/
resources/icons/ resources/icons/
resources/sounds/ resources/sounds/
@@ -397,6 +398,17 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe -WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe
``` ```
对于 MSVC 构建,脚本会从 Visual Studio Redistributable 目录复制 x64 CRT DLL,确保 ZIP 和安装版无需预装 Visual C++ 运行库即可启动。建议从 Visual Studio Developer PowerShell/Command Prompt 运行;也可以显式传入运行库目录:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe `
-MsvcRuntimeDir "D:\VisualStudio\VC\Redist\MSVC\<version>\x64\Microsoft.VC143.CRT"
```
MSVC 运行库目录缺失或不完整时,脚本会在删除旧发布目录前终止,不生成可能无法在干净电脑启动的产物。
生成 Inno Setup 安装器: 生成 Inno Setup 安装器:
```powershell ```powershell
+4 -1
View File
@@ -30,7 +30,8 @@
- 天气查询: - 天气查询:
- Open-Meteo Forecast API - Open-Meteo Forecast API
- Open-Meteo Geocoding API - Open-Meteo Geocoding API
- 默认城市、公网 IP 定位兜底、多候选提示 - 默认城市、可信同名候选提示,以及常用海外城市中文名解析
- 公网 IP 定位依次尝试 ipapi.co 和 ipwho.is,单个服务故障时自动切换
- Windows 发布脚本和 Inno Setup 安装器脚本。 - Windows 发布脚本和 Inno Setup 安装器脚本。
## Platform ## Platform
@@ -118,6 +119,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-BuildInstaller -BuildInstaller
``` ```
For MSVC builds, run the package script from a Visual Studio developer shell or pass `-MsvcRuntimeDir`. The script deploys the x64 MSVC runtime beside the application and fails early when the required runtime files are unavailable.
The installer supports optional desktop shortcut creation and optional Windows startup launch. Both are disabled by default. The installer supports optional desktop shortcut creation and optional Windows startup launch. Both are disabled by default.
## Runtime Data ## Runtime Data
@@ -169,7 +169,7 @@ Qt 版本:
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1
``` ```
当前自动化覆盖意图分发、提醒解析和类型、天气解析和模板、联网能力检测、聊天历史兼容,以及源码登记、废弃模块残留、默认资源和 GitHub 导出隔离。真实窗口、托盘通知、音效、网络 Provider、安装卸载仍按手测执行。 当前自动化覆盖意图分发、提醒解析和类型、天气解析/城市别名/IP 定位响应/模板、联网能力检测、聊天历史兼容、配置损坏恢复与 AI 诊断脱敏。静态检查覆盖源码登记、废弃模块残留、默认资源、GitHub 导出隔离、发布包必要输入和安装器关键安全项。真实窗口、托盘通知、音效、外部网络服务、Release 二进制和安装卸载仍按手测执行。
## 发布与性能 ## 发布与性能
@@ -177,7 +177,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-S
| 用例 | 步骤 | 预期结果 | 结论 | | 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Release 构建 | 用户手动构建 Release | 构建成功,无新增编译错误 | TODO | | Release 构建 | 用户手动构建 Release | 构建成功,无新增编译错误 | TODO |
| 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、resources、README、LICENSE | TODO | | 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、MSVC x64 runtime、resources、README、LICENSE;MSVC 运行库缺失时打包直接失败 | TODO |
| 安装器 | 生成并安装 Inno Setup 包 | 无 Qt 开发环境机器可运行 | TODO | | 安装器 | 生成并安装 Inno Setup 包 | 无 Qt 开发环境机器可运行 | TODO |
| 安装器自启动选项 | 安装页面勾选/不勾选开机自启动分别安装 | 勾选时写入当前用户 Run 项;不勾选时不新增自启动项;选项默认不勾选 | TODO | | 安装器自启动选项 | 安装页面勾选/不勾选开机自启动分别安装 | 勾选时写入当前用户 Run 项;不勾选时不新增自启动项;选项默认不勾选 | TODO |
| 卸载清理自启动 | 已启用开机自启动后卸载 | 当前用户 Run 项被清理,不残留开机启动入口 | TODO | | 卸载清理自启动 | 已启用开机自启动后卸载 | 当前用户 Run 项被清理,不残留开机启动入口 | TODO |
@@ -211,4 +211,4 @@ UI 状态:
| 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 | | 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 |
| 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 | | 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 |
| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 | | 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 |
| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;性能文档覆盖静置、AI、提醒、天气和联网模式 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 | | 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;静态规则验证发布输入、内部 QA 隔离、安装器自启动与卸载清理声明 | 产品 Release、`windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 内部 QA 已独立验证;产品 Release 与安装器等待用户构建输入 |
@@ -4,12 +4,12 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 测试日期 | 2026-07-17 | | 测试日期 | 2026-07-17 至 2026-07-18;2026-09-08 至 2026-09-09 复测 |
| 版本 / 发布阶段 | 当前开发版 / 预发布回归进行中 | | 版本 / 发布阶段 | `0.2.0` / 发布候选验收完成 |
| 测试源码基线 | `d0d1de4-dirty`;包含已确认的产品能力收缩、无用代码清理和内部 QA 基础设施 | | 测试源码基线 | `90262aa` + 本记录所在提交;`90262aa` 已推送开发仓库 |
| 工作树说明 | 用户手测后只新增测试记录,未再修改产品业务代码 | | 工作树说明 | 连接测试、提醒按钮定位、天气城市/IP 定位、诊断脱敏、QA 扩展、中文安装器和发布脚本已完成收口 |
| 构建类型 | 用户未反馈,待补充 | | 构建类型 | Release |
| 编译器 / Qt | 待补充 | | 编译器 / Qt | Qt 6.5.3 / MSVC 19.44 |
| 操作系统 | Windows,具体版本待补充 | | 操作系统 | Windows,具体版本待补充 |
| 显示器 / DPI | 待补充 | | 显示器 / DPI | 待补充 |
| 测试人 | 用户手动测试 | | 测试人 | 用户手动测试 |
@@ -18,8 +18,8 @@
| 检查 | 结果 | 摘要 | | 检查 | 结果 | 摘要 |
| --- | --- | --- | | --- | --- | --- |
| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;尚未手动构建内部 QA 工程 | | 核心逻辑 C++ 测试 | PASS | 2026-09-08 使用 Qt 6.5.3 / MSVC 19.44 构建并执行隔离 QA,43 项通过,0 项失败 |
| 静态检查 | PASS | 13 项通过,0 项失败 | | 静态检查 | PASS | 14 项通过,0 项失败;覆盖发布包、MSVC 运行库、中文安装器和隔离规则 |
| 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 | | 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 |
| GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA | | GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA |
@@ -34,21 +34,114 @@
| 单实例唤醒 | PASS | 用户确认通过 | | 单实例唤醒 | PASS | 用户确认通过 |
| 设置页居中 / 置前 | PASS | 用户确认通过 | | 设置页居中 / 置前 | PASS | 用户确认通过 |
| 应用设置保存及重启保持 | PASS | 用户确认通过 | | 应用设置保存及重启保持 | PASS | 用户确认通过 |
| 开机自启动 | WAIVED | 用户于 2026-09-08 决定免测并接受当前实现,不作为技术验证 PASS |
| 导入合法角色包 | PASS | 用户于 2026-07-18 确认通过 |
| 切换用户角色并重启保持 | PASS | 用户于 2026-07-18 确认通过 |
| 同 ID 用户角色取消覆盖 | PASS | 用户于 2026-07-18 确认通过 |
| 拒绝缺少 `character.json` 的角色包 | PASS | 用户于 2026-07-18 确认通过 |
| 拒绝缺少 `idle` 状态的角色包 | PASS | 用户于 2026-07-18 确认通过 |
| 内置角色删除保护 | PASS | 用户于 2026-07-18 确认通过 |
| 导出角色及文件完整性 | PASS | 用户于 2026-07-18 确认通过 |
| 导出目标冲突确认与取消保护 | PASS | 用户于 2026-07-18 确认通过 |
| 打开用户角色目录 | PASS | 用户于 2026-07-18 确认通过 |
| 删除当前用户角色并回退默认角色 | PASS | 用户于 2026-07-18 确认通过 |
| AI 配置保存并重启保持 | PASS | 用户于 2026-07-18 确认通过 |
| 正确配置连接测试 | PASS | `AI-CONNECT-001` 修复后,用户于 2026-09-08 使用原配置复测通过 |
| 错误 Key / Base URL 错误提示 | PASS | 用户于 2026-07-18 确认通过 |
| 普通聊天 | PASS | 用户于 2026-07-18 确认通过 |
| 流式回复 | PASS | 用户于 2026-07-18 确认通过 |
| AI 忙时拒绝第二个请求 | PASS | 用户于 2026-07-18 确认通过 |
| 取消 AI 请求 | PASS | 用户于 2026-07-18 确认通过 |
| 本地历史保存和重启恢复 | PASS | 用户于 2026-07-18 确认通过 |
| 历史关键词搜索 | PASS | 用户于 2026-07-18 确认通过 |
| Provider / 模型筛选 | PASS | 用户于 2026-07-18 确认通过 |
| 导出 Markdown | PASS | 用户于 2026-07-18 确认通过 |
| 导出 JSON | PASS | 用户于 2026-07-18 确认通过 |
| 关闭历史保存后不持久化新消息 | PASS | 用户于 2026-07-18 确认通过 |
| 清空聊天历史及二次确认 | PASS | 用户于 2026-07-18 确认通过 |
| 一次性提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 中文相对时间 | PASS | 用户于 2026-09-08 确认通过 |
| 绝对日期 | PASS | 用户于 2026-09-08 确认通过 |
| 每天 / 每周 / 每月重复提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 复杂重复规则拒绝 | PASS | 用户于 2026-09-08 确认通过 |
| 提醒列表 | PASS | 用户于 2026-09-08 确认通过 |
| 聊天取消提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 设置页编辑 pending | PASS | 用户于 2026-09-08 确认通过 |
| 桌宠可见时触发 | PASS | 动态定位修复后,用户于 2026-09-08 复测通过 |
| 桌宠隐藏时触发 | PASS | 用户于 2026-09-08 确认通过 |
| AI 忙时触发 | PASS | 用户于 2026-09-08 确认通过 |
| 拖动中触发 | PASS | 用户于 2026-09-08 确认通过 |
| 多提醒同时触发 | PASS | 用户于 2026-09-08 确认通过 |
| 知道了 | PASS | 用户于 2026-09-08 确认通过 |
| 5分钟后再提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 20天历史清理 | PASS | 用户于 2026-09-08 确认通过 |
| 自定义音效导入 / 试听 / 删除 | PASS | 用户于 2026-09-08 确认通过 |
| 内置音效保护 | PASS | 用户于 2026-09-08 确认通过 |
| 睡眠与系统时间变化 | PASS | 用户于 2026-09-08 确认通过 |
| 提醒保存失败回滚与重试 | PASS | 用户于 2026-09-08 确认通过 |
| 洛杉矶天气 | PASS | 用户于 2026-09-08 确认可识别并返回天气 |
| 华盛顿天气 | PASS | 用户于 2026-09-08 确认可识别并返回天气 |
| 西安天气候选 | PASS | 低价值同名候选过滤修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 纽约天气 | PASS | 中文海外城市别名修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 东京天气 | PASS | 中文别名与候选过滤修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 日本东京天气 | PASS | 国家限定解析修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 公网 IP 定位 | PASS | `ipapi.co` 失败后切换 `ipwho.is` 的备用链路通过用户复测 |
| 文件操作已移除 | PASS | 用户于 2026-09-08 确认不再弹文件选择框或访问本地文件 |
| 应用启动已移除 | PASS | 用户于 2026-09-08 确认不再弹 exe 选择/确认框或启动程序 |
| 工作区 Agent 已移除 | PASS | 用户于 2026-09-08 确认不再扫描目录或打开 Agent 窗口 |
| 本地能力菜单入口清理 | PASS | 用户于 2026-09-08 确认右键与托盘入口已清理 |
| 本地能力设置页清理 | PASS | 用户于 2026-09-08 确认应用启动和 Agent 页面已清理 |
| Agent 快捷键清理 | PASS | 用户于 2026-09-08 确认 `Alt+A` 不再呼出 Agent |
| 旧本地能力配置兼容 | PASS | 用户于 2026-09-08 确认保留旧配置启动不崩溃且不恢复旧能力 |
| `0.2.0` Release 构建 | PASS | 用户于 2026-09-08 使用 Qt 6.5.3 / MSVC Release Kit 完成构建 |
| 中文安装器界面 | PASS | 用户于 2026-09-09 确认安装器实机验收通过 |
| 桌面快捷方式和开机自启默认未选 | PASS | 用户于 2026-09-09 确认默认选项符合预期 |
| 安装完成并启动 | PASS | 用户于 2026-09-09 确认通过 |
| 覆盖安装 | PASS | 用户于 2026-09-09 确认通过 |
| 卸载时保留用户数据 | PASS | 用户于 2026-09-09 确认通过 |
| 卸载时删除用户数据 | PASS | 用户于 2026-09-09 确认通过 |
## T09 自动化验证
| 用例 | 结果 | 实际结果 / 备注 |
| --- | --- | --- |
| App 配置损坏 | PASS | 损坏文件被备份,加载默认 AppConfig |
| AI 配置损坏 | PASS | 损坏文件被备份,回退未配置的 Custom Provider |
| 天气配置损坏 | PASS | 损坏文件被备份,回退 Open-Meteo 默认配置 |
| 联网配置损坏 | PASS | 损坏文件被备份,回退默认联网开关配置 |
| 提醒数据损坏 | PASS | 损坏文件被备份,返回空提醒列表 |
| 历史数据损坏 | PASS | 既有 ConversationStore 自动化验证备份并忽略损坏历史 |
| 日志隐私 | PASS | 诊断上下文、JSON 摘要和错误摘要均不泄露 Key、Bearer、token、message/content 内容 |
| 同秒重复损坏 | PASS | 连续损坏生成带后缀的独立备份,不覆盖前一份 |
## T10 发布与安装验收
| 检查 | 结果 | 实际结果 / 备注 |
| --- | --- | --- |
| PowerShell / JSON 语法 | PASS | QA、性能、发布和 GitHub 导出脚本均可解析 |
| 发布包输入 | PASS | 包含 exe、Qt runtime、应用本地 MSVC x64 runtime、characters/icons/sounds、README、LICENSE |
| 发布隔离 | PASS | 发布脚本不复制 `docs/internal/reports`,产品 CMake/打包脚本不依赖内部 QA |
| 安装器结构 | PASS | 简体中文、per-user 安装、x64、文件递归、自启动默认未选、Run 项和卸载清理逻辑均已验证 |
| MSVC 依赖闭合 | PASS | `MSVCP140/140_1/140_2` 与 `VCRUNTIME140/140_1` 均随包部署,依赖扫描无缺失 |
| ZIP 发布包 | PASS | `QtDesktopPet-0.2.0-windows-x64.zip`,SHA256 `A247A6AB7EC467B2FC1EA2439611660E9E269E573860EDD587E7D65243768661` |
| 中文安装器 | PASS | `QtDesktopPet-0.2.0-windows-x64-setup.exe`,SHA256 `0FE7E215C649E4C2175736C8798BF25F1FDEB34C1B45632CD8824F3F6DDEE709` |
| Release / 实际打包 / 安装卸载 | PASS | 用户完成 Release 构建,并于 2026-09-09 确认中文安装、覆盖安装、启动和卸载数据选项通过 |
| 数字签名 | ACCEPTED_LIMITATION | 当前安装器未签名,Windows SmartScreen 可能显示“未知发布者”;不作为 `0.2.0` 预发布阻断项 |
## 批次状态 ## 批次状态
| 批次 | 范围 | 结果 | 备注 | | 批次 | 范围 | 结果 | 备注 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 | | T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 |
| T02 | 应用设置与开机自启动 | IN_PROGRESS | 应用设置持久化通过;开机自启动尚未测试 | | T02 | 应用设置与开机自启动 | ACCEPTED | 应用设置持久化通过;开机自启动由用户决定免测并接受 |
| T03 | 角色管理 | NOT_RUN | | | T03 | 角色管理 | PASS | 10 项全部通过 |
| T04 | AI 与聊天历史 | NOT_RUN | | | T04 | AI 与聊天历史 | PASS | 连接测试修复后复测通过;本批次全部通过 |
| T05 | 定时提醒 | NOT_RUN | | | T05 | 定时提醒 | PASS | 动态按钮定位复测通过,提醒批次关闭 |
| T06 | 天气查询 | NOT_RUN | | | T06 | 天气查询 | PASS | 海外中文城市、候选过滤、国家限定和 IP 备用服务均通过最小清单复测 |
| T07 | 联网模式 | NOT_RUN | | | T07 | 联网模式 | BLOCKED | 当前没有可用的 OpenAI/Gemini 官方 Key;不是功能失败 |
| T08 | 已移除能力与安全边界 | NOT_RUN | | | T08 | 已移除能力与安全边界 | PASS | 用户确认全部 7 项通过 |
| T09 | 配置损坏与隐私 | NOT_RUN | | | T09 | 配置损坏与隐私 | PASS | 8 项自动化验证通过,测试仅使用 Qt 测试专用配置目录 |
| T10 | 打包、安装与卸载 | NOT_RUN | | | T10 | 打包、安装与卸载 | PASS | `0.2.0` 中文安装器、运行库闭合、安装启动、覆盖安装及卸载均通过 |
## 性能状态 ## 性能状态
@@ -56,11 +149,16 @@
## 缺陷与复测 ## 缺陷与复测
当前已执行项目未发现缺陷。 | 缺陷 ID | 首次现象 | 严重级别 | 修复状态 | 复测结果 |
| --- | --- | --- | --- | --- |
| AI-CONNECT-001 | 正常聊天可用,但连接测试返回 `HTTP 200: AI response content is empty.` | Medium | 已移除测试请求的 16 token 限制,并补充 token 截断提示 | PASS / CLOSED(2026-09-08) |
| REMINDER-UI-001 | 到点提醒的“知道了”和“5分钟后再提醒”按钮距角色主体偏远 | Low | 已改为按当前角色帧 Alpha 可见边界和可见尺寸动态定位,并保留屏幕边缘回退 | PASS / CLOSED(2026-09-08) |
| WEATHER-GEO-001 | 西安出现低价值候选,纽约/东京中文名无法稳定解析,国家限定失败 | Medium | 增加集中式中文城市别名、国家限定、人口/地点等级候选过滤,并修复中文被误判为两位国家码 | PASS / CLOSED(2026-09-08) |
| WEATHER-IP-001 | ipapi.co 服务端错误或超时导致公网 IP 定位失败 | Medium | 改为 ipapi.co 失败或 5 秒超时后自动尝试 ipwho.is,全部失败才提示配置默认城市 | PASS / CLOSED(2026-09-08) |
## 当前结论 ## 当前结论
- 已执行手测:7 项 `PASS`。 - 已执行手测:72 项 `PASS`,0 项 `FAIL`,1 项 `WAIVED`。
- 已执行静态检查:13 项 `PASS`。 - 已执行自动化:核心逻辑 C++ 测试 43 项 `PASS`,静态检查 14 项 `PASS`。
- 未执行项目仍较多,本记录状态为 `IN_PROGRESS`。 - T07 因缺少官方联网 Key 处于 `BLOCKED`,不是已发现的功能失败;产品性能场景尚未执行。
- 当前不能据此判定可发布。 - `0.2.0` 可作为预发布版本发布;已知限制为联网模式未实测、安装器未数字签名,公开分发时应明确说明。
+1 -1
View File
@@ -2,7 +2,7 @@
| 日期 | 版本 / 阶段 | Commit | 构建 | 自动化 | 手测 | 性能 | 发布结论 | 记录 | | 日期 | 版本 / 阶段 | Commit | 构建 | 自动化 | 手测 | 性能 | 发布结论 | 记录 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2026-07-17 | 预发布回归进行中 | `d0d1de4-dirty` | 用户构建,详情待补 | 静态 13/13;C++ NOT_RUN | 7 PASS,其余 NOT_RUN | NOT_RUN | IN_PROGRESS | `2026-07-17_pre-release_d0d1de4-dirty.md` | | 2026-07-17 至 2026-07-18;2026-09-08 至 2026-09-09 复测 | `0.2.0` 发布候选 | 本记录所在提交 | Release / Qt 6.5.3 / MSVC 19.44 | 静态 14/14;C++ 43/43 | 72 PASS / 0 FAIL / 1 WAIVED;T07 无 Key 未测 | NOT_RUN | PASS_WITH_LIMITATIONS | `2026-07-17_pre-release_d0d1de4-dirty.md` |
| 2026-07-17 | QA 基础设施 | `d0d1de4-dirty` | 未构建 | 静态 13/13;C++ NOT_RUN | NOT_RUN | fixture PASS | NOT EVALUATED | `2026-07-17_qa-infrastructure_d0d1de4-dirty.md` | | 2026-07-17 | QA 基础设施 | `d0d1de4-dirty` | 未构建 | 静态 13/13;C++ NOT_RUN | NOT_RUN | fixture PASS | NOT EVALUATED | `2026-07-17_qa-infrastructure_d0d1de4-dirty.md` |
正式记录按时间倒序添加。模板和维护规则分别见 `TEMPLATE.md` 与 `README.md`。 正式记录按时间倒序添加。模板和维护规则分别见 `TEMPLATE.md` 与 `README.md`。
+10 -6
View File
@@ -1,9 +1,9 @@
#define AppName "QtDesktopPet" #define AppName "QtDesktopPet"
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.1.0" #define AppVersion "0.2.0"
#endif #endif
#ifndef SourceDir #ifndef SourceDir
#define SourceDir "..\dist\QtDesktopPet-0.1.0-windows-x64" #define SourceDir "..\dist\QtDesktopPet-0.2.0-windows-x64"
#endif #endif
#ifndef OutputDir #ifndef OutputDir
#define OutputDir "..\dist\installer" #define OutputDir "..\dist\installer"
@@ -22,15 +22,19 @@ OutputBaseFilename=QtDesktopPet-{#AppVersion}-windows-x64-setup
Compression=lzma2 Compression=lzma2
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
ShowLanguageDialog=no
PrivilegesRequired=lowest PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
SetupIconFile=..\resources\icons\app_icon.ico SetupIconFile=..\resources\icons\app_icon.ico
UninstallDisplayIcon={app}\QtDesktopPet.exe UninstallDisplayIcon={app}\QtDesktopPet.exe
[Languages]
Name: "chinesesimplified"; MessagesFile: ".\languages\ChineseSimplified.isl"
[Tasks] [Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加快捷方式:"; Flags: unchecked
Name: "startup"; Description: "开机自启动"; GroupDescription: "Additional options:"; Flags: unchecked Name: "startup"; Description: "开机自启动"; GroupDescription: "附加选项:"; Flags: unchecked
[Files] [Files]
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
@@ -40,11 +44,11 @@ Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType:
[Icons] [Icons]
Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}" Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"
Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}" Name: "{group}\卸载 QtDesktopPet"; Filename: "{uninstallexe}"
Name: "{userdesktop}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"; Tasks: desktopicon Name: "{userdesktop}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"; Tasks: desktopicon
[Run] [Run]
Filename: "{app}\QtDesktopPet.exe"; Description: "Launch QtDesktopPet"; Flags: nowait postinstall skipifsilent Filename: "{app}\QtDesktopPet.exe"; Description: "启动 QtDesktopPet"; Flags: nowait postinstall skipifsilent
[Code] [Code]
var var
+417
View File
@@ -0,0 +1,417 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintainer: Zhenghan Yang (Kira)
; Email: 847320916@QQ.com
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; Encoding: UTF-8
; Translation based on network resource
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; LanguageCodePage should always be set if possible, even if this file is Unicode
; For English it's set to zero anyway because English only uses ASCII characters
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** Application titles
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** Startup error messages
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序已在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** Startup questions
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
; *** Misc. errors
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。
; *** Setup common messages
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** Buttons
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** "Select Language" dialog messages
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** Common wizard text
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** "Welcome" wizard page
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** "Password" wizard page
WizardPassword=密码
PasswordLabel1=此安装程序需要密码验证。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** "License Agreement" wizard page
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** "Information" wizard pages
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** "User Information" wizard page
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=请输入用户名。
; *** "Select Destination Location" wizard page
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** "Select Components" wizard page
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** "Select Additional Tasks" wizard page
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** "Select Start Menu Folder" wizard page
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名称。
GroupNameTooLong=文件夹名称或路径太长。
InvalidGroupName=文件夹名称无效。
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** "Ready to Install" wizard page
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止。
ErrorDownloadFailed=下载失败:%1 %2。
ErrorDownloadSizeFailed=获取大小失败:%1 %2。
ErrorProgress=无效的进度:%1 / %2。
ErrorFileSize=文件大小错误:预期 %1,实际 %2。
; *** TExtractionWizardPage wizard page and ExtractArchive
ExtractingLabel=正在提取文件...
ButtonStopExtraction=停止提取(&S)
StopExtraction=您确定要停止提取吗?
ErrorExtractionAborted=提取已中止。
ErrorExtractionFailed=提取失败:%1
; *** Archive extraction failure details
ArchiveIncorrectPassword=密码不正确。
ArchiveIsCorrupted=压缩包已损坏。
ArchiveUnsupportedFormat=不支持的压缩包格式。
; *** "Preparing to Install" wizard page
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
; *** "Installing" wizard page
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
; *** "Setup Completed" wizard page
FinishedHeadingLabel=完成 [name] 安装向导
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启计算机(&Y)
NoRadio=否,稍后重启计算机(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** "Setup Needs the Next Disk" stuff
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** Installation phase messages
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=取消安装
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消
; *** Installation status messages
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** Misc. errors
ErrorInternal2=内部错误:%1。
ErrorFunctionFailedNoCode=%1 失败。
ErrorFunctionFailed=%1 失败;错误代码 %2。
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** Registry errors
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI errors
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** File copying errors
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
SourceIsCorrupted=源文件已损坏。
SourceDoesntExist=源文件“%1”不存在。
SourceVerificationFailed=源文件验证失败:%1
VerificationSignatureDoesntExist=签名文件“%1”不存在。
VerificationSignatureInvalid=签名文件“%1”无效。
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。
VerificationFileNameIncorrect=文件名不正确。
VerificationFileTagIncorrect=文件标签不正确。
VerificationFileSizeIncorrect=文件大小不正确。
VerificationFileHashIncorrect=文件哈希值不正确。
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留已存在的文件(&K)
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=尝试下载文件时出错:
ErrorExtracting=尝试提取压缩包时出错:
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。
ErrorRegisterTypeLib=无法注册类型库:%1
; *** Uninstall display name markings
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** Post-installation errors
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
; *** Uninstaller messages
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
UninstalledAll=已顺利从您的计算机中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载。
; *** Uninstallation phase messages
ConfirmDeleteSharedFileTitle=删除共享文件?
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 - 2020 kirakira
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# Inno Setup 简体中文翻译
`ChineseSimplified.isl` 来源:
https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
该翻译适用于 Inno Setup 6.5 及以上版本,按同目录 `LICENSE.txt` 中的 MIT 许可证使用。
+19 -2
View File
@@ -14,7 +14,7 @@ if (NOT EXISTS "${PET_SOURCE_ROOT}/src" OR NOT EXISTS "${PET_SOURCE_ROOT}/CMakeL
message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}") message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}")
endif() endif()
find_package(Qt6 REQUIRED COMPONENTS Core) find_package(Qt6 REQUIRED COMPONENTS Core Network)
add_executable(QtDesktopPetCoreTests add_executable(QtDesktopPetCoreTests
include/TestHarness.h include/TestHarness.h
@@ -24,6 +24,8 @@ add_executable(QtDesktopPetCoreTests
tests/weather/WeatherTests.cpp tests/weather/WeatherTests.cpp
tests/web/WebCapabilityTests.cpp tests/web/WebCapabilityTests.cpp
tests/ai/ConversationStoreTests.cpp tests/ai/ConversationStoreTests.cpp
tests/ai/AIDiagnosticsTests.cpp
tests/config/ConfigRecoveryTests.cpp
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h ${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp ${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h ${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h
@@ -35,6 +37,10 @@ add_executable(QtDesktopPetCoreTests
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.cpp ${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherParser.h ${PET_SOURCE_ROOT}/src/weather/WeatherParser.h
${PET_SOURCE_ROOT}/src/weather/WeatherParser.cpp ${PET_SOURCE_ROOT}/src/weather/WeatherParser.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.h
${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherManager.h
${PET_SOURCE_ROOT}/src/weather/WeatherManager.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h ${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp ${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h ${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h
@@ -45,9 +51,20 @@ add_executable(QtDesktopPetCoreTests
${PET_SOURCE_ROOT}/src/web/WebConfig.h ${PET_SOURCE_ROOT}/src/web/WebConfig.h
${PET_SOURCE_ROOT}/src/config/AIConfig.h ${PET_SOURCE_ROOT}/src/config/AIConfig.h
${PET_SOURCE_ROOT}/src/config/AIConfig.cpp ${PET_SOURCE_ROOT}/src/config/AIConfig.cpp
${PET_SOURCE_ROOT}/src/config/AppConfig.h
${PET_SOURCE_ROOT}/src/config/ConfigManager.h
${PET_SOURCE_ROOT}/src/config/ConfigManager.cpp
${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.h
${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.cpp
${PET_SOURCE_ROOT}/src/ai/ConversationStore.h ${PET_SOURCE_ROOT}/src/ai/ConversationStore.h
${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp ${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp
${PET_SOURCE_ROOT}/src/ai/LLMTypes.h ${PET_SOURCE_ROOT}/src/ai/LLMTypes.h
${PET_SOURCE_ROOT}/src/weather/WeatherStore.h
${PET_SOURCE_ROOT}/src/weather/WeatherStore.cpp
${PET_SOURCE_ROOT}/src/web/WebStore.h
${PET_SOURCE_ROOT}/src/web/WebStore.cpp
${PET_SOURCE_ROOT}/src/reminder/ReminderStore.h
${PET_SOURCE_ROOT}/src/reminder/ReminderStore.cpp
${PET_SOURCE_ROOT}/src/util/Logger.h ${PET_SOURCE_ROOT}/src/util/Logger.h
${PET_SOURCE_ROOT}/src/util/Logger.cpp ${PET_SOURCE_ROOT}/src/util/Logger.cpp
) )
@@ -58,7 +75,7 @@ target_include_directories(QtDesktopPetCoreTests
"${PET_SOURCE_ROOT}" "${PET_SOURCE_ROOT}"
) )
target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core) target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core Qt6::Network)
include(CTest) include(CTest)
add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests) add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests)
+2
View File
@@ -27,6 +27,8 @@ ctest --test-dir build/internal-qa --output-on-failure
The test process enables Qt test mode and uses temporary directories for file The test process enables Qt test mode and uses temporary directories for file
fixtures. It must not read or modify the normal QtDesktopPet user configuration. fixtures. It must not read or modify the normal QtDesktopPet user configuration.
To isolate a suite while diagnosing failures, run the test executable with a
case-insensitive filter, for example `QtDesktopPetCoreTests.exe --filter=Weather`.
## Static Checks ## Static Checks
+50 -1
View File
@@ -40,6 +40,8 @@
"resources/characters/shiroko/preview.png", "resources/characters/shiroko/preview.png",
"resources/icons/app_icon.ico", "resources/icons/app_icon.ico",
"resources/sounds/reminders/reminder_default.wav", "resources/sounds/reminders/reminder_default.wav",
"installer/languages/ChineseSimplified.isl",
"installer/languages/LICENSE.txt",
"tools/package_release.ps1", "tools/package_release.ps1",
"tools/prepare_github_export.ps1" "tools/prepare_github_export.ps1"
], ],
@@ -55,5 +57,52 @@
"releaseForbiddenPatterns": [ "releaseForbiddenPatterns": [
"internal/qa", "internal/qa",
"internal\\\\qa" "internal\\\\qa"
] ],
"releaseValidation": {
"powershellParseFiles": [
"internal/qa/scripts/Invoke-StaticChecks.ps1",
"internal/qa/scripts/New-PerformanceReport.ps1",
"internal/qa/scripts/perf_sample.ps1",
"tools/package_release.ps1",
"tools/prepare_github_export.ps1"
],
"packageScript": {
"path": "tools/package_release.ps1",
"requiredPatterns": [
{ "name": "Qt deployment tool", "pattern": "windeployqt" },
{ "name": "release runtime deployment", "pattern": "--release" },
{ "name": "compiler runtime deployment", "pattern": "--compiler-runtime" },
{ "name": "MSVC app-local deployment", "pattern": "Copy-MsvcRuntime" },
{ "name": "MSVC runtime input", "pattern": "MsvcRuntimeDir" },
{ "name": "MSVC runtime validation", "pattern": "vcruntime140_1\\.dll" },
{ "name": "character resources", "pattern": "resources\\\\characters" },
{ "name": "icon resources", "pattern": "resources\\\\icons" },
{ "name": "sound resources", "pattern": "resources\\\\sounds" },
{ "name": "README", "pattern": "README\\.md" },
{ "name": "LICENSE", "pattern": "LICENSE" },
{ "name": "installer build switch", "pattern": "BuildInstaller" },
{ "name": "Inno Setup compiler", "pattern": "ISCC\\.exe" }
],
"forbiddenPatterns": [
{ "name": "internal QA copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*internal[\\\\/]qa" },
{ "name": "development docs copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*docs" },
{ "name": "test reports copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*reports" }
]
},
"installerScript": {
"path": "installer/QtDesktopPet.iss",
"requiredPatterns": [
{ "name": "per-user install", "pattern": "PrivilegesRequired=lowest" },
{ "name": "x64 compatibility", "pattern": "ArchitecturesAllowed=x64compatible" },
{ "name": "Simplified Chinese installer", "pattern": "ChineseSimplified\\.isl" },
{ "name": "language dialog disabled", "pattern": "ShowLanguageDialog=no" },
{ "name": "unchecked startup option", "pattern": "Name:\\s*\"startup\"[^\\r\\n]*Flags:\\s*unchecked" },
{ "name": "startup registry entry", "pattern": "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run" },
{ "name": "recursive package files", "pattern": "recursesubdirs\\s+createallsubdirs" },
{ "name": "uninstall data prompt", "pattern": "InitializeUninstall" },
{ "name": "optional user data cleanup", "pattern": "DeleteUserDataAfterUninstall" },
{ "name": "startup registry cleanup", "pattern": "RegDeleteValue" }
]
}
}
} }
+29 -1
View File
@@ -258,8 +258,36 @@ Add-Result -Name "Production build and packaging do not depend on internal QA" `
-Passed ($releaseIsolationMatches.Count -eq 0) ` -Passed ($releaseIsolationMatches.Count -eq 0) `
-Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " })) -Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " }))
$releaseValidationProblems = New-Object System.Collections.Generic.List[string]
$packageValidation = $config.releaseValidation.packageScript
$packageScriptPath = Resolve-AbsolutePath -Path ([string]$packageValidation.path) -BasePath $resolvedRoot
$packageScriptText = Get-Content -LiteralPath $packageScriptPath -Raw -Encoding UTF8
foreach ($rule in $packageValidation.requiredPatterns) {
if ($packageScriptText -notmatch ([string]$rule.pattern)) {
$releaseValidationProblems.Add("package missing $($rule.name)")
}
}
foreach ($rule in $packageValidation.forbiddenPatterns) {
if ($packageScriptText -match ([string]$rule.pattern)) {
$releaseValidationProblems.Add("package contains $($rule.name)")
}
}
$installerValidation = $config.releaseValidation.installerScript
$installerScriptPath = Resolve-AbsolutePath -Path ([string]$installerValidation.path) -BasePath $resolvedRoot
$installerScriptText = Get-Content -LiteralPath $installerScriptPath -Raw -Encoding UTF8
foreach ($rule in $installerValidation.requiredPatterns) {
if ($installerScriptText -notmatch ([string]$rule.pattern)) {
$releaseValidationProblems.Add("installer missing $($rule.name)")
}
}
Add-Result -Name "Release packaging and installer contain required safeguards" `
-Passed ($releaseValidationProblems.Count -eq 0) `
-Details ($(if ($releaseValidationProblems.Count -eq 0) { "Package inputs, QA isolation, startup option, and uninstall cleanup are declared" } else { $releaseValidationProblems -join "; " }))
$infrastructureProblems = New-Object System.Collections.Generic.List[string] $infrastructureProblems = New-Object System.Collections.Generic.List[string]
foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) { foreach ($relativeScriptPath in $config.releaseValidation.powershellParseFiles) {
$scriptFile = Get-Item -LiteralPath (Resolve-AbsolutePath -Path ([string]$relativeScriptPath) -BasePath $resolvedRoot)
$tokens = $null $tokens = $null
$parseErrors = $null $parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile( [System.Management.Automation.Language.Parser]::ParseFile(
+20 -2
View File
@@ -15,14 +15,32 @@ int main(int argc, char *argv[])
QTextStream output(stdout); QTextStream output(stdout);
QTextStream errors(stderr); QTextStream errors(stderr);
QString filter;
for (int index = 1; index < argc; ++index)
{
const QString argument = QString::fromLocal8Bit(argv[index]);
if (argument.startsWith(QStringLiteral("--filter=")))
{
filter = argument.mid(QStringLiteral("--filter=").size()).trimmed();
}
}
int failedCount = 0; int failedCount = 0;
int executedCount = 0;
for (const Qa::TestCase &test : Qa::registry()) for (const Qa::TestCase &test : Qa::registry())
{ {
const QString testName = QString::fromStdString(test.name);
if (!filter.isEmpty() && !testName.contains(filter, Qt::CaseInsensitive))
{
continue;
}
++executedCount;
try try
{ {
test.function(); test.function();
output << "[PASS] " << QString::fromStdString(test.name) << Qt::endl; output << "[PASS] " << testName << Qt::endl;
} }
catch (const std::exception &exception) catch (const std::exception &exception)
{ {
@@ -38,7 +56,7 @@ int main(int argc, char *argv[])
} }
} }
output << "Executed " << Qa::registry().size() << " tests; " output << "Executed " << executedCount << " tests; "
<< failedCount << " failed." << Qt::endl; << failedCount << " failed." << Qt::endl;
return failedCount == 0 ? 0 : 1; return failedCount == 0 ? 0 : 1;
} }
@@ -0,0 +1,53 @@
#include "TestHarness.h"
#include "src/ai/AIDiagnostics.h"
#include <QByteArray>
#include <QUrl>
QA_TEST(AIDiagnostics, RedactsCredentialsFromContextAndFreeText)
{
AIConfig config;
config.provider = QStringLiteral("custom");
config.protocol = QStringLiteral("openai-compatible");
config.model = QStringLiteral("test-model");
config.apiKey = QStringLiteral("sk-super-secret-value");
const QString context = AIDiagnostics::diagnosticContext(
config,
QUrl(QStringLiteral("https://example.test/v1/chat?api_key=query-secret&trace=visible")));
const QString freeText = AIDiagnostics::safeTextSummary(
QStringLiteral("Authorization: Bearer bearer-secret token=token-secret sk-another-secret-value"));
QA_EXPECT(!context.contains(QStringLiteral("sk-super-secret-value")));
QA_EXPECT(!context.contains(QStringLiteral("query-secret")));
QA_EXPECT(context.contains(QStringLiteral("trace=visible")));
QA_EXPECT(!freeText.contains(QStringLiteral("bearer-secret")));
QA_EXPECT(!freeText.contains(QStringLiteral("token-secret")));
QA_EXPECT(!freeText.contains(QStringLiteral("sk-another-secret-value")));
}
QA_TEST(AIDiagnostics, RedactsConversationAndCredentialFieldsFromJson)
{
const QByteArray body = QByteArrayLiteral(
R"({"safe":"visible","apiKey":"api-secret","authorization":"Bearer header-secret","messages":[{"content":"private prompt"}],"nested":{"token":"token-secret"}})");
const QString summary = AIDiagnostics::responseBodySummary(body);
QA_EXPECT(summary.contains(QStringLiteral("visible")));
QA_EXPECT(!summary.contains(QStringLiteral("api-secret")));
QA_EXPECT(!summary.contains(QStringLiteral("header-secret")));
QA_EXPECT(!summary.contains(QStringLiteral("private prompt")));
QA_EXPECT(!summary.contains(QStringLiteral("token-secret")));
}
QA_TEST(AIDiagnostics, KeepsUsefulErrorMetadataWithoutCredentialLeakage)
{
const QByteArray body = QByteArrayLiteral(
R"({"error":{"message":"API key sk-super-secret-value is invalid","code":"invalid_api_key","type":"authentication_error"},"content":"private response"})");
const QString error = AIDiagnostics::errorMessageFromBody(body);
QA_EXPECT(error.contains(QStringLiteral("invalid")));
QA_EXPECT(error.contains(QStringLiteral("invalid_api_key")));
QA_EXPECT(!error.contains(QStringLiteral("sk-super-secret-value")));
QA_EXPECT(!error.contains(QStringLiteral("private response")));
}
@@ -0,0 +1,153 @@
#include "TestHarness.h"
#include "src/config/ConfigManager.h"
#include "src/reminder/ReminderStore.h"
#include "src/weather/WeatherStore.h"
#include "src/web/WebStore.h"
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QFileInfo>
namespace
{
bool writeBrokenJson(const QString &filePath)
{
QDir().mkpath(QFileInfo(filePath).absolutePath());
QFile file(filePath);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
&& file.write(QByteArrayLiteral("{broken")) == 7;
}
QString qaConfigDirectory()
{
return QFileInfo(ConfigManager().appConfigPath()).absolutePath();
}
bool isQaConfigDirectory(const QString &path)
{
return path.contains(QStringLiteral("QtDesktopPetInternalQA"), Qt::CaseInsensitive)
|| path.contains(QStringLiteral("qttest"), Qt::CaseInsensitive);
}
void clearQaConfigFiles()
{
const QString path = qaConfigDirectory();
if (!isQaConfigDirectory(path))
{
return;
}
QDir directory(path);
const QStringList filters = {
QStringLiteral("app_config.json"),
QStringLiteral("app_config.broken.*.json"),
QStringLiteral("ai_config.json"),
QStringLiteral("ai_config.broken.*.json"),
QStringLiteral("weather_config.json"),
QStringLiteral("weather_config.broken.*.json"),
QStringLiteral("web_config.json"),
QStringLiteral("web_config.broken.*.json"),
QStringLiteral("reminders.json"),
QStringLiteral("reminders.broken.*.json"),
};
for (const QString &fileName : directory.entryList(filters, QDir::Files))
{
directory.remove(fileName);
}
}
int backupCount(const QString &pattern)
{
return QDir(qaConfigDirectory()).entryList({pattern}, QDir::Files).size();
}
class QaConfigFiles
{
public:
QaConfigFiles()
{
QA_EXPECT(isQaConfigDirectory(qaConfigDirectory()));
clearQaConfigFiles();
}
~QaConfigFiles()
{
clearQaConfigFiles();
}
};
}
QA_TEST(ConfigRecovery, AppConfigFallsBackAndBacksUpBrokenJson)
{
QaConfigFiles files;
ConfigManager manager;
QA_EXPECT(writeBrokenJson(manager.appConfigPath()));
const AppConfig config = manager.loadAppConfig();
QA_EXPECT_EQ(config.windowPosition, QPoint(100, 100));
QA_EXPECT(config.alwaysOnTop);
QA_EXPECT(!QFile::exists(manager.appConfigPath()));
QA_EXPECT_EQ(backupCount(QStringLiteral("app_config.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, AiConfigFallsBackAndBacksUpBrokenJson)
{
QaConfigFiles files;
ConfigManager manager;
QA_EXPECT(writeBrokenJson(manager.aiConfigPath()));
const AIConfigStore store = manager.loadAIConfigStore();
QA_EXPECT_EQ(store.activeProvider, QStringLiteral("custom"));
QA_EXPECT(store.providers.isEmpty());
QA_EXPECT(!QFile::exists(manager.aiConfigPath()));
QA_EXPECT_EQ(backupCount(QStringLiteral("ai_config.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, WeatherAndWebConfigsFallBackAndBackUpBrokenJson)
{
QaConfigFiles files;
WeatherStore weatherStore;
WebStore webStore;
QA_EXPECT(writeBrokenJson(weatherStore.storePath()));
QA_EXPECT(writeBrokenJson(webStore.storePath()));
QString weatherError;
QString webError;
const WeatherConfig weather = weatherStore.load(&weatherError);
const WebConfig web = webStore.load(&webError);
QA_EXPECT_EQ(weather.provider, QStringLiteral("open-meteo"));
QA_EXPECT(weather.autoLocateWhenNoDefault);
QA_EXPECT(web.enabled);
QA_EXPECT(!web.defaultToggleOn);
QA_EXPECT(!weatherError.isEmpty());
QA_EXPECT(!webError.isEmpty());
QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 1);
QA_EXPECT_EQ(backupCount(QStringLiteral("web_config.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, ReminderStoreFallsBackToEmptyAndBacksUpBrokenJson)
{
QaConfigFiles files;
ReminderStore store;
QA_EXPECT(writeBrokenJson(store.storePath()));
QString error;
QA_EXPECT(store.load(&error).isEmpty());
QA_EXPECT(!error.isEmpty());
QA_EXPECT(!QFile::exists(store.storePath()));
QA_EXPECT_EQ(backupCount(QStringLiteral("reminders.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, SameSecondBackupsNeverOverwriteEarlierFiles)
{
QaConfigFiles files;
WeatherStore store;
QA_EXPECT(writeBrokenJson(store.storePath()));
store.load();
QA_EXPECT(writeBrokenJson(store.storePath()));
store.load();
QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 2);
}
+140
View File
@@ -1,8 +1,34 @@
#include "TestHarness.h" #include "TestHarness.h"
#include "src/weather/WeatherParser.h" #include "src/weather/WeatherParser.h"
#include "src/weather/WeatherLocationResolver.h"
#include "src/weather/WeatherSummaryFormatter.h" #include "src/weather/WeatherSummaryFormatter.h"
namespace
{
WeatherLocationCandidate candidate(
const QString &city,
const QString &admin,
const QString &country,
const QString &countryCode,
const QString &featureCode,
qint64 population,
double latitude,
double longitude)
{
WeatherLocationCandidate value;
value.cityName = city;
value.adminName = admin;
value.countryName = country;
value.countryCode = countryCode;
value.featureCode = featureCode;
value.population = population;
value.latitude = latitude;
value.longitude = longitude;
return value;
}
}
QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates) QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates)
{ {
const WeatherParser parser; const WeatherParser parser;
@@ -38,6 +64,120 @@ QA_TEST(WeatherParser, RejectsUnsupportedQueries)
QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty()); QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty());
} }
QA_TEST(WeatherLocationResolver, ResolvesChineseInternationalCities)
{
const WeatherLocationSearch newYork = WeatherLocationResolver::resolveSearch(QStringLiteral("纽约"));
const WeatherLocationSearch tokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京"));
const WeatherLocationSearch japanTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京"));
const WeatherLocationSearch qualifiedTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京,日本"));
QA_EXPECT_EQ(newYork.queryName, QStringLiteral("New York"));
QA_EXPECT_EQ(newYork.countryCode, QStringLiteral("US"));
QA_EXPECT_EQ(tokyo.queryName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(tokyo.countryCode, QStringLiteral("JP"));
QA_EXPECT_EQ(japanTokyo.queryName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(japanTokyo.countryCode, QStringLiteral("JP"));
QA_EXPECT_EQ(qualifiedTokyo.queryName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(qualifiedTokyo.countryCode, QStringLiteral("JP"));
}
QA_TEST(WeatherLocationResolver, PreservesDomesticCityAndCountryQualifier)
{
const WeatherLocationSearch xian = WeatherLocationResolver::resolveSearch(QStringLiteral("西安市"));
const WeatherLocationSearch chinaXian = WeatherLocationResolver::resolveSearch(QStringLiteral("中国西安"));
QA_EXPECT_EQ(xian.queryName, QStringLiteral("西安"));
QA_EXPECT(xian.countryCode.isEmpty());
QA_EXPECT_EQ(chinaXian.queryName, QStringLiteral("西安"));
QA_EXPECT_EQ(chinaXian.countryCode, QStringLiteral("CN"));
}
QA_TEST(WeatherLocationResolver, SuppressesLowValueAlternatives)
{
const QVector<WeatherLocationCandidate> candidates = {
candidate(QStringLiteral("西安"), QStringLiteral("陕西"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPLA"), 6500000, 34.26, 108.93),
candidate(QStringLiteral("西安"), QStringLiteral("湖南"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), 1200, 27.10, 111.20),
candidate(QStringLiteral("西安"), QStringLiteral("广东"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), -1, 23.10, 113.20),
};
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
candidates,
WeatherLocationResolver::resolveSearch(QStringLiteral("西安")));
QA_EXPECT_EQ(selected.size(), 1);
QA_EXPECT(!WeatherLocationResolver::hasAmbiguity(selected));
}
QA_TEST(WeatherLocationResolver, KeepsPlausibleAmbiguity)
{
const QVector<WeatherLocationCandidate> candidates = {
candidate(QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA2"), 170000, 37.21, -93.29),
candidate(QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA"), 114000, 39.80, -89.64),
candidate(QStringLiteral("Springfield"), QStringLiteral("Georgia"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 2700, 32.37, -81.31),
};
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
candidates,
WeatherLocationResolver::resolveSearch(QStringLiteral("Springfield")));
QA_EXPECT_EQ(selected.size(), 2);
QA_EXPECT(WeatherLocationResolver::hasAmbiguity(selected));
}
QA_TEST(WeatherLocationResolver, AppliesExplicitCountryFilter)
{
const QVector<WeatherLocationCandidate> candidates = {
candidate(QStringLiteral("Tokyo"), QStringLiteral("Tokyo"), QStringLiteral("Japan"), QStringLiteral("JP"), QStringLiteral("PPLC"), 8300000, 35.68, 139.69),
candidate(QStringLiteral("Tokyo"), QStringLiteral("Texas"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 1000, 33.90, -102.50),
};
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
candidates,
WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京")));
QA_EXPECT_EQ(selected.size(), 1);
QA_EXPECT_EQ(selected.first().countryCode, QStringLiteral("JP"));
}
QA_TEST(WeatherLocationResolver, ParsesBothIpLocationSchemas)
{
const QByteArray ipApiPayload = QByteArrayLiteral(
R"({"city":"Xi'an","region":"Shaanxi","country_name":"China","latitude":34.26,"longitude":108.94,"timezone":"Asia/Shanghai"})");
const QByteArray ipWhoIsPayload = QByteArrayLiteral(
R"({"success":true,"city":"Tokyo","region":"Tokyo","country":"Japan","latitude":35.68,"longitude":139.69,"timezone":{"id":"Asia/Tokyo"}})");
WeatherLocation ipApiLocation;
WeatherLocation ipWhoIsLocation;
QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse(
ipApiPayload,
WeatherIpLocationProvider::IpApiCo,
&ipApiLocation));
QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse(
ipWhoIsPayload,
WeatherIpLocationProvider::IpWhoIs,
&ipWhoIsLocation));
QA_EXPECT_EQ(ipApiLocation.cityName, QStringLiteral("Xi'an"));
QA_EXPECT_EQ(ipApiLocation.countryName, QStringLiteral("China"));
QA_EXPECT_EQ(ipApiLocation.timezone, QStringLiteral("Asia/Shanghai"));
QA_EXPECT_EQ(ipWhoIsLocation.cityName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(ipWhoIsLocation.countryName, QStringLiteral("Japan"));
QA_EXPECT_EQ(ipWhoIsLocation.timezone, QStringLiteral("Asia/Tokyo"));
}
QA_TEST(WeatherLocationResolver, RejectsBrokenIpLocationResponses)
{
WeatherLocation location;
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
QByteArrayLiteral("{broken"),
WeatherIpLocationProvider::IpApiCo,
&location));
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
QByteArrayLiteral(R"({"success":false,"message":"rate limited"})"),
WeatherIpLocationProvider::IpWhoIs,
&location));
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
QByteArrayLiteral(R"({"city":"Tokyo","latitude":35.68})"),
WeatherIpLocationProvider::IpWhoIs,
&location));
}
QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections) QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections)
{ {
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴")); QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴"));
+21 -1
View File
@@ -5,14 +5,34 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError> #include <QJsonParseError>
#include <QJsonValue> #include <QJsonValue>
#include <QRegularExpression>
#include <QStringList> #include <QStringList>
#include <QUrlQuery> #include <QUrlQuery>
#include <QVector>
#include <QtGlobal> #include <QtGlobal>
namespace namespace
{ {
constexpr int MaxBodySummaryLength = 360; constexpr int MaxBodySummaryLength = 360;
QString redactSensitiveText(QString text)
{
static const QVector<QRegularExpression> credentialPatterns = {
QRegularExpression(QStringLiteral("\\bBearer\\s+[^\\s,;]+"), QRegularExpression::CaseInsensitiveOption),
QRegularExpression(QStringLiteral("\\bsk-[A-Za-z0-9_-]{8,}"), QRegularExpression::CaseInsensitiveOption),
QRegularExpression(QStringLiteral("\\bAIza[A-Za-z0-9_-]{20,}")),
QRegularExpression(
QStringLiteral("\\b(api[_-]?key|token|authorization|password|secret)\\s*[:=]\\s*[^\\s,;]+"),
QRegularExpression::CaseInsensitiveOption),
};
for (const QRegularExpression &pattern : credentialPatterns)
{
text.replace(pattern, QStringLiteral("<redacted>"));
}
return text;
}
bool isSensitiveName(const QString &name) bool isSensitiveName(const QString &name)
{ {
const QString lowerName = name.toLower(); const QString lowerName = name.toLower();
@@ -96,7 +116,7 @@ QString oneLine(QString text)
text.replace(QLatin1Char('\r'), QLatin1Char(' ')); text.replace(QLatin1Char('\r'), QLatin1Char(' '));
text.replace(QLatin1Char('\n'), QLatin1Char(' ')); text.replace(QLatin1Char('\n'), QLatin1Char(' '));
text.replace(QLatin1Char('\t'), QLatin1Char(' ')); text.replace(QLatin1Char('\t'), QLatin1Char(' '));
return text.simplified(); return redactSensitiveText(text.simplified());
} }
QString safeTextSummary(const QString &text, int maxLength) QString safeTextSummary(const QString &text, int maxLength)
+12 -1
View File
@@ -361,14 +361,25 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
return {false, {}, QStringLiteral("AI response has no choices."), httpStatus}; return {false, {}, QStringLiteral("AI response has no choices."), httpStatus};
} }
const QJsonObject message = choices.first().toObject().value(QStringLiteral("message")).toObject(); const QJsonObject choice = choices.first().toObject();
const QJsonObject message = choice.value(QStringLiteral("message")).toObject();
const QString content = message.value(QStringLiteral("content")).toString(); const QString content = message.value(QStringLiteral("content")).toString();
if (content.isEmpty()) if (content.isEmpty())
{ {
const QString finishReason = choice.value(QStringLiteral("finish_reason")).toString().trimmed();
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"") Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(AIDiagnostics::responseBodySummary(body))); .arg(AIDiagnostics::responseBodySummary(body)));
if (finishReason == QStringLiteral("length"))
{
return {
false,
{},
QStringLiteral("AI response reached the token limit before producing content. Increase Max Tokens."),
httpStatus,
};
}
return {false, {}, QStringLiteral("AI response content is empty."), httpStatus}; return {false, {}, QStringLiteral("AI response content is empty."), httpStatus};
} }
+80 -6
View File
@@ -34,6 +34,7 @@
#include <QFrame> #include <QFrame>
#include <QGuiApplication> #include <QGuiApplication>
#include <QHideEvent> #include <QHideEvent>
#include <QImage>
#include <QList> #include <QList>
#include <QMenu> #include <QMenu>
#include <QMessageBox> #include <QMessageBox>
@@ -66,6 +67,51 @@ constexpr int ChatFinishedReturnDelayMs = 1500;
constexpr int StandardPrewarmIntervalMs = 800; constexpr int StandardPrewarmIntervalMs = 800;
constexpr int LowPowerPrewarmIntervalMs = 1500; constexpr int LowPowerPrewarmIntervalMs = 1500;
constexpr qint64 BytesPerMegabyte = 1024 * 1024; constexpr qint64 BytesPerMegabyte = 1024 * 1024;
constexpr int ReminderVisibleAlphaThreshold = 24;
QRect visiblePixmapRect(const QPixmap &pixmap)
{
if (pixmap.isNull())
{
return {};
}
QImage image = pixmap.toImage();
if (!image.hasAlphaChannel())
{
return image.rect();
}
image = image.convertToFormat(QImage::Format_ARGB32);
int left = image.width();
int top = image.height();
int right = -1;
int bottom = -1;
for (int y = 0; y < image.height(); ++y)
{
const QRgb *line = reinterpret_cast<const QRgb *>(image.constScanLine(y));
for (int x = 0; x < image.width(); ++x)
{
if (qAlpha(line[x]) < ReminderVisibleAlphaThreshold)
{
continue;
}
left = qMin(left, x);
top = qMin(top, y);
right = qMax(right, x);
bottom = qMax(bottom, y);
}
}
if (right < left || bottom < top)
{
return {};
}
return QRect(QPoint(left, top), QPoint(right, bottom));
}
int boundedAnimationTargetSide(double sideLength) int boundedAnimationTargetSide(double sideLength)
{ {
@@ -1102,21 +1148,22 @@ void PetWindow::updateReminderActionPosition()
m_reminderActionPanel->adjustSize(); m_reminderActionPanel->adjustSize();
const QSize panelSize = m_reminderActionPanel->sizeHint(); const QSize panelSize = m_reminderActionPanel->sizeHint();
const QRect petGeometry = frameGeometry(); const QRect petGeometry = visiblePetGeometry();
constexpr int PanelSideSpacing = 8; const int petReferenceSide = qMin(petGeometry.width(), petGeometry.height());
const int panelSideSpacing = qBound(4, qRound(static_cast<double>(petReferenceSide) * 0.015), 10);
QPoint position( QPoint position(
petGeometry.right() + PanelSideSpacing, petGeometry.right() + 1 + panelSideSpacing,
petGeometry.center().y() - panelSize.height() / 2); petGeometry.center().y() - panelSize.height() / 2);
if (QScreen *screen = screenForPopup(this)) if (QScreen *screen = screenForPopup(this))
{ {
const QRect availableGeometry = screen->availableGeometry(); const QRect availableGeometry = screen->availableGeometry();
const int maxX = qMax(availableGeometry.left(), availableGeometry.right() - panelSize.width()); const int maxX = qMax(availableGeometry.left(), availableGeometry.right() - panelSize.width() + 1);
const int maxY = qMax(availableGeometry.top(), availableGeometry.bottom() - panelSize.height()); const int maxY = qMax(availableGeometry.top(), availableGeometry.bottom() - panelSize.height() + 1);
if (position.x() > maxX) if (position.x() > maxX)
{ {
position.setX(petGeometry.left() - panelSize.width() - PanelSideSpacing); position.setX(petGeometry.left() - panelSize.width() - panelSideSpacing);
} }
position.setX(qBound( position.setX(qBound(
@@ -1708,6 +1755,33 @@ QPoint PetWindow::bubbleAnchorPosition() const
return frameGeometry().topLeft() + QPoint(qRound(localAnchor.x()), qRound(localAnchor.y())); return frameGeometry().topLeft() + QPoint(qRound(localAnchor.x()), qRound(localAnchor.y()));
} }
QRect PetWindow::visiblePetGeometry() const
{
if (m_petView == nullptr)
{
return frameGeometry();
}
const QPixmap pixmap = m_petView->pixmap();
if (pixmap.isNull())
{
return frameGeometry();
}
if (m_visiblePetRectPixmapKey != pixmap.cacheKey())
{
m_visiblePetRectPixmapKey = pixmap.cacheKey();
m_visiblePetRectCache = visiblePixmapRect(pixmap);
}
if (!m_visiblePetRectCache.isValid())
{
return frameGeometry();
}
return m_visiblePetRectCache.translated(frameGeometry().topLeft());
}
void PetWindow::playState(const QString &stateName, bool centerWindow) void PetWindow::playState(const QString &stateName, bool centerWindow)
{ {
playResolvedState(m_stateMachine.requestState(stateName), centerWindow); playResolvedState(m_stateMachine.requestState(stateName), centerWindow);
+4
View File
@@ -10,6 +10,7 @@
#include <QMap> #include <QMap>
#include <QPoint> #include <QPoint>
#include <QRect>
#include <QSet> #include <QSet>
#include <QStringList> #include <QStringList>
#include <QTimer> #include <QTimer>
@@ -108,6 +109,7 @@ private:
QPoint chatInputAnchorPosition() const; QPoint chatInputAnchorPosition() const;
void updateBubblePosition(); void updateBubblePosition();
QPoint bubbleAnchorPosition() const; QPoint bubbleAnchorPosition() const;
QRect visiblePetGeometry() const;
void playState(const QString &stateName, bool centerWindow); void playState(const QString &stateName, bool centerWindow);
void playResolvedState(const QString &stateName, bool centerWindow); void playResolvedState(const QString &stateName, bool centerWindow);
QSize animationTargetSize() const; QSize animationTargetSize() const;
@@ -170,4 +172,6 @@ private:
bool m_streamingTalkStarted = false; bool m_streamingTalkStarted = false;
bool m_settingsFallbackInContextMenuEnabled = true; bool m_settingsFallbackInContextMenuEnabled = true;
bool m_hasActiveTriggeredReminder = false; bool m_hasActiveTriggeredReminder = false;
mutable qint64 m_visiblePetRectPixmapKey = 0;
mutable QRect m_visiblePetRectCache;
}; };
-1
View File
@@ -1571,7 +1571,6 @@ AIConfig SettingsDialog::runtimeConfigFromForm(const QString &provider) const
config.stream = false; config.stream = false;
config.temperature = 0.0; config.temperature = 0.0;
config.maxTokens = qMin(config.maxTokens, 16);
return config; return config;
} }
+390
View File
@@ -0,0 +1,390 @@
#include "WeatherLocationResolver.h"
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QRegularExpression>
#include <QSet>
#include <QStringList>
#include <QtGlobal>
namespace
{
struct CityAlias
{
QString queryName;
QString countryCode;
};
QString compactKey(QString text)
{
text = text.trimmed().toLower();
text.remove(QRegularExpression(QStringLiteral("[\\s,,、。.]+")));
return text;
}
QString cityAliasKey(QString text)
{
text = compactKey(text);
if (text.size() > 2 && text.endsWith(QStringLiteral("市")))
{
text.chop(1);
}
return text;
}
const QVector<QPair<QString, QString>> &countryAliases()
{
static const QVector<QPair<QString, QString>> aliases = {
{QStringLiteral("中华人民共和国"), QStringLiteral("CN")},
{QStringLiteral("United States"), QStringLiteral("US")},
{QStringLiteral("United Kingdom"), QStringLiteral("GB")},
{QStringLiteral("澳大利亚"), QStringLiteral("AU")},
{QStringLiteral("马来西亚"), QStringLiteral("MY")},
{QStringLiteral("印度尼西亚"), QStringLiteral("ID")},
{QStringLiteral("新西兰"), QStringLiteral("NZ")},
{QStringLiteral("新加坡"), QStringLiteral("SG")},
{QStringLiteral("加拿大"), QStringLiteral("CA")},
{QStringLiteral("菲律宾"), QStringLiteral("PH")},
{QStringLiteral("俄罗斯"), QStringLiteral("RU")},
{QStringLiteral("西班牙"), QStringLiteral("ES")},
{QStringLiteral("阿联酋"), QStringLiteral("AE")},
{QStringLiteral("土耳其"), QStringLiteral("TR")},
{QStringLiteral("墨西哥"), QStringLiteral("MX")},
{QStringLiteral("意大利"), QStringLiteral("IT")},
{QStringLiteral("Australia"), QStringLiteral("AU")},
{QStringLiteral("Malaysia"), QStringLiteral("MY")},
{QStringLiteral("Indonesia"), QStringLiteral("ID")},
{QStringLiteral("Singapore"), QStringLiteral("SG")},
{QStringLiteral("Canada"), QStringLiteral("CA")},
{QStringLiteral("Philippines"), QStringLiteral("PH")},
{QStringLiteral("Russia"), QStringLiteral("RU")},
{QStringLiteral("Germany"), QStringLiteral("DE")},
{QStringLiteral("France"), QStringLiteral("FR")},
{QStringLiteral("Britain"), QStringLiteral("GB")},
{QStringLiteral("England"), QStringLiteral("GB")},
{QStringLiteral("Japan"), QStringLiteral("JP")},
{QStringLiteral("中国"), QStringLiteral("CN")},
{QStringLiteral("美国"), QStringLiteral("US")},
{QStringLiteral("英国"), QStringLiteral("GB")},
{QStringLiteral("法国"), QStringLiteral("FR")},
{QStringLiteral("德国"), QStringLiteral("DE")},
{QStringLiteral("日本"), QStringLiteral("JP")},
{QStringLiteral("韩国"), QStringLiteral("KR")},
{QStringLiteral("印度"), QStringLiteral("IN")},
{QStringLiteral("泰国"), QStringLiteral("TH")},
{QStringLiteral("越南"), QStringLiteral("VN")},
{QStringLiteral("巴西"), QStringLiteral("BR")},
{QStringLiteral("埃及"), QStringLiteral("EG")},
{QStringLiteral("China"), QStringLiteral("CN")},
{QStringLiteral("USA"), QStringLiteral("US")},
{QStringLiteral("Korea"), QStringLiteral("KR")},
{QStringLiteral("India"), QStringLiteral("IN")},
{QStringLiteral("Thailand"), QStringLiteral("TH")},
{QStringLiteral("Vietnam"), QStringLiteral("VN")},
{QStringLiteral("Brazil"), QStringLiteral("BR")},
{QStringLiteral("Mexico"), QStringLiteral("MX")},
{QStringLiteral("Egypt"), QStringLiteral("EG")},
{QStringLiteral("Turkey"), QStringLiteral("TR")},
};
return aliases;
}
QString countryCodeForText(const QString &text)
{
const QString key = compactKey(text);
const auto isAsciiLetter = [](QChar character) {
const ushort value = character.unicode();
return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z');
};
if (key.size() == 2 && isAsciiLetter(key.at(0)) && isAsciiLetter(key.at(1)))
{
return key.toUpper();
}
for (const auto &alias : countryAliases())
{
if (key == compactKey(alias.first))
{
return alias.second;
}
}
return {};
}
const QHash<QString, CityAlias> &cityAliases()
{
static const QHash<QString, CityAlias> aliases = {
{QStringLiteral("纽约"), {QStringLiteral("New York"), QStringLiteral("US")}},
{QStringLiteral("东京"), {QStringLiteral("Tokyo"), QStringLiteral("JP")}},
{QStringLiteral("伦敦"), {QStringLiteral("London"), QStringLiteral("GB")}},
{QStringLiteral("巴黎"), {QStringLiteral("Paris"), QStringLiteral("FR")}},
{QStringLiteral("柏林"), {QStringLiteral("Berlin"), QStringLiteral("DE")}},
{QStringLiteral("罗马"), {QStringLiteral("Rome"), QStringLiteral("IT")}},
{QStringLiteral("马德里"), {QStringLiteral("Madrid"), QStringLiteral("ES")}},
{QStringLiteral("莫斯科"), {QStringLiteral("Moscow"), QStringLiteral("RU")}},
{QStringLiteral("首尔"), {QStringLiteral("Seoul"), QStringLiteral("KR")}},
{QStringLiteral("曼谷"), {QStringLiteral("Bangkok"), QStringLiteral("TH")}},
{QStringLiteral("吉隆坡"), {QStringLiteral("Kuala Lumpur"), QStringLiteral("MY")}},
{QStringLiteral("雅加达"), {QStringLiteral("Jakarta"), QStringLiteral("ID")}},
{QStringLiteral("河内"), {QStringLiteral("Hanoi"), QStringLiteral("VN")}},
{QStringLiteral("马尼拉"), {QStringLiteral("Manila"), QStringLiteral("PH")}},
{QStringLiteral("新德里"), {QStringLiteral("New Delhi"), QStringLiteral("IN")}},
{QStringLiteral("孟买"), {QStringLiteral("Mumbai"), QStringLiteral("IN")}},
{QStringLiteral("悉尼"), {QStringLiteral("Sydney"), QStringLiteral("AU")}},
{QStringLiteral("墨尔本"), {QStringLiteral("Melbourne"), QStringLiteral("AU")}},
{QStringLiteral("奥克兰"), {QStringLiteral("Auckland"), QStringLiteral("NZ")}},
{QStringLiteral("多伦多"), {QStringLiteral("Toronto"), QStringLiteral("CA")}},
{QStringLiteral("温哥华"), {QStringLiteral("Vancouver"), QStringLiteral("CA")}},
{QStringLiteral("墨西哥城"), {QStringLiteral("Mexico City"), QStringLiteral("MX")}},
{QStringLiteral("圣保罗"), {QStringLiteral("Sao Paulo"), QStringLiteral("BR")}},
{QStringLiteral("里约热内卢"), {QStringLiteral("Rio de Janeiro"), QStringLiteral("BR")}},
{QStringLiteral("开罗"), {QStringLiteral("Cairo"), QStringLiteral("EG")}},
{QStringLiteral("伊斯坦布尔"), {QStringLiteral("Istanbul"), QStringLiteral("TR")}},
{QStringLiteral("迪拜"), {QStringLiteral("Dubai"), QStringLiteral("AE")}},
};
return aliases;
}
QString candidateKey(const WeatherLocationCandidate &candidate)
{
return compactKey(candidate.cityName)
+ QLatin1Char('|')
+ compactKey(candidate.adminName)
+ QLatin1Char('|')
+ compactKey(candidate.countryCode)
+ QLatin1Char('|')
+ QString::number(candidate.latitude, 'f', 4)
+ QLatin1Char('|')
+ QString::number(candidate.longitude, 'f', 4);
}
int placeRank(const QString &featureCode)
{
const QString code = featureCode.trimmed().toUpper();
if (code == QStringLiteral("PPLC"))
{
return 5;
}
if (code == QStringLiteral("PPLA"))
{
return 4;
}
if (code == QStringLiteral("PPLA2"))
{
return 3;
}
if (code == QStringLiteral("PPLA3") || code == QStringLiteral("PPLA4"))
{
return 2;
}
if (code == QStringLiteral("PPL"))
{
return 1;
}
return 0;
}
bool isPlausibleAlternative(
const WeatherLocationCandidate &selected,
const WeatherLocationCandidate &candidate)
{
if (selected.population > 0 && candidate.population > 0)
{
const qint64 populationFloor = qMax<qint64>(50000, selected.population / 5);
if (candidate.population >= populationFloor)
{
return true;
}
}
const int selectedRank = placeRank(selected.featureCode);
const int candidateRank = placeRank(candidate.featureCode);
if (candidateRank >= 3 && candidateRank >= selectedRank - 1)
{
return true;
}
return selected.population <= 0
&& candidate.population <= 0
&& qAbs(candidateRank - selectedRank) <= 1;
}
}
WeatherLocationSearch WeatherLocationResolver::resolveSearch(const QString &cityText)
{
WeatherLocationSearch result;
result.originalName = cityText.trimmed();
QString cityName = result.originalName.simplified();
const QStringList qualifiedParts = cityName.split(
QRegularExpression(QStringLiteral("[,,]")),
Qt::SkipEmptyParts);
if (qualifiedParts.size() >= 2)
{
const QString trailingCountryCode = countryCodeForText(qualifiedParts.last());
const QString leadingCountryCode = countryCodeForText(qualifiedParts.first());
if (!trailingCountryCode.isEmpty())
{
result.countryCode = trailingCountryCode;
cityName = qualifiedParts.first().trimmed();
}
else if (!leadingCountryCode.isEmpty())
{
result.countryCode = leadingCountryCode;
cityName = qualifiedParts.last().trimmed();
}
}
if (result.countryCode.isEmpty())
{
const QString compactCityName = compactKey(cityName);
for (const auto &alias : countryAliases())
{
const QString aliasKey = compactKey(alias.first);
if (compactCityName.startsWith(aliasKey) && compactCityName.size() > aliasKey.size() + 1)
{
result.countryCode = alias.second;
cityName = compactCityName.mid(aliasKey.size());
break;
}
if (compactCityName.endsWith(aliasKey) && compactCityName.size() > aliasKey.size() + 1)
{
result.countryCode = alias.second;
cityName = compactCityName.left(compactCityName.size() - aliasKey.size());
break;
}
}
}
const QString aliasKey = cityAliasKey(cityName);
const auto alias = cityAliases().constFind(aliasKey);
if (alias != cityAliases().constEnd())
{
result.queryName = alias->queryName;
if (result.countryCode.isEmpty())
{
result.countryCode = alias->countryCode;
}
}
else
{
result.queryName = cityName.trimmed();
if (result.queryName.size() > 2 && result.queryName.endsWith(QStringLiteral("市")))
{
result.queryName.chop(1);
}
}
result.countryCode = result.countryCode.trimmed().toUpper();
return result;
}
bool WeatherLocationResolver::parseIpLocationResponse(
const QByteArray &payload,
WeatherIpLocationProvider provider,
WeatherLocation *location)
{
if (location == nullptr)
{
return false;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return false;
}
const QJsonObject root = document.object();
if (root.value(QStringLiteral("error")).toBool(false)
|| (root.contains(QStringLiteral("success")) && !root.value(QStringLiteral("success")).toBool(true)))
{
return false;
}
const QJsonValue latitude = root.value(QStringLiteral("latitude"));
const QJsonValue longitude = root.value(QStringLiteral("longitude"));
const QString cityName = root.value(QStringLiteral("city")).toString().trimmed();
if (!latitude.isDouble() || !longitude.isDouble() || cityName.isEmpty())
{
return false;
}
WeatherLocation parsed;
parsed.cityName = cityName;
parsed.adminName = root.value(QStringLiteral("region")).toString().trimmed();
parsed.countryName = provider == WeatherIpLocationProvider::IpApiCo
? root.value(QStringLiteral("country_name")).toString().trimmed()
: root.value(QStringLiteral("country")).toString().trimmed();
const QJsonValue timezone = root.value(QStringLiteral("timezone"));
parsed.timezone = timezone.isObject()
? timezone.toObject().value(QStringLiteral("id")).toString().trimmed()
: timezone.toString().trimmed();
parsed.latitude = latitude.toDouble();
parsed.longitude = longitude.toDouble();
parsed.source = WeatherLocationSource::IpFallback;
*location = parsed;
return true;
}
QVector<WeatherLocationCandidate> WeatherLocationResolver::selectRelevantCandidates(
const QVector<WeatherLocationCandidate> &candidates,
const WeatherLocationSearch &search)
{
QVector<WeatherLocationCandidate> uniqueCandidates;
QSet<QString> seen;
for (const WeatherLocationCandidate &candidate : candidates)
{
if (!search.countryCode.isEmpty()
&& !candidate.countryCode.isEmpty()
&& candidate.countryCode.compare(search.countryCode, Qt::CaseInsensitive) != 0)
{
continue;
}
const QString key = candidateKey(candidate);
if (seen.contains(key))
{
continue;
}
seen.insert(key);
uniqueCandidates.append(candidate);
}
if (uniqueCandidates.size() <= 1)
{
return uniqueCandidates;
}
QVector<WeatherLocationCandidate> relevantCandidates;
relevantCandidates.append(uniqueCandidates.first());
for (int index = 1; index < uniqueCandidates.size(); ++index)
{
if (isPlausibleAlternative(uniqueCandidates.first(), uniqueCandidates.at(index)))
{
relevantCandidates.append(uniqueCandidates.at(index));
}
}
return relevantCandidates;
}
bool WeatherLocationResolver::hasAmbiguity(const QVector<WeatherLocationCandidate> &candidates)
{
if (candidates.size() <= 1)
{
return false;
}
const QString firstKey = candidateKey(candidates.first());
for (int index = 1; index < candidates.size(); ++index)
{
if (candidateKey(candidates.at(index)) != firstKey)
{
return true;
}
}
return false;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "WeatherTypes.h"
#include <QByteArray>
class WeatherLocationResolver
{
public:
static WeatherLocationSearch resolveSearch(const QString &cityText);
static bool parseIpLocationResponse(
const QByteArray &payload,
WeatherIpLocationProvider provider,
WeatherLocation *location);
static QVector<WeatherLocationCandidate> selectRelevantCandidates(
const QVector<WeatherLocationCandidate> &candidates,
const WeatherLocationSearch &search);
static bool hasAmbiguity(const QVector<WeatherLocationCandidate> &candidates);
};
+94 -64
View File
@@ -1,6 +1,7 @@
#include "WeatherManager.h" #include "WeatherManager.h"
#include "../util/Logger.h" #include "../util/Logger.h"
#include "WeatherLocationResolver.h"
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
@@ -18,6 +19,8 @@ namespace
{ {
constexpr int ForecastDays = 4; constexpr int ForecastDays = 4;
constexpr int GeocodingCandidateCount = 5; constexpr int GeocodingCandidateCount = 5;
constexpr int IpLocationProviderCount = 2;
constexpr int IpLocationAttemptTimeoutMs = 5000;
QString normalizedProvider(const WeatherConfig &config) QString normalizedProvider(const WeatherConfig &config)
{ {
@@ -82,38 +85,6 @@ QJsonArray arrayValue(const QJsonObject &object, const QString &key)
return value.isArray() ? value.toArray() : QJsonArray(); return value.isArray() ? value.toArray() : QJsonArray();
} }
QString normalizedCandidateText(const QString &text)
{
return text.trimmed().toLower();
}
QString candidateKey(const WeatherLocationCandidate &candidate)
{
return normalizedCandidateText(candidate.cityName)
+ QLatin1Char('|')
+ normalizedCandidateText(candidate.adminName)
+ QLatin1Char('|')
+ normalizedCandidateText(candidate.countryName);
}
bool hasCandidateAmbiguity(const QVector<WeatherLocationCandidate> &candidates)
{
if (candidates.size() <= 1)
{
return false;
}
const QString firstKey = candidateKey(candidates.first());
for (int index = 1; index < candidates.size(); ++index)
{
if (candidateKey(candidates.at(index)) != firstKey)
{
return true;
}
}
return false;
}
WeatherLocation locationFromCandidate(const WeatherLocationCandidate &candidate, WeatherLocationSource source) WeatherLocation locationFromCandidate(const WeatherLocationCandidate &candidate, WeatherLocationSource source)
{ {
WeatherLocation location; WeatherLocation location;
@@ -149,6 +120,13 @@ QVector<WeatherLocationCandidate> candidatesFromGeocodingResults(const QJsonArra
candidate.adminName = city.value(QStringLiteral("admin1")).toString(); candidate.adminName = city.value(QStringLiteral("admin1")).toString();
candidate.countryName = city.value(QStringLiteral("country")).toString(); candidate.countryName = city.value(QStringLiteral("country")).toString();
candidate.timezone = city.value(QStringLiteral("timezone")).toString(); candidate.timezone = city.value(QStringLiteral("timezone")).toString();
candidate.countryCode = city.value(QStringLiteral("country_code")).toString().trimmed().toUpper();
candidate.featureCode = city.value(QStringLiteral("feature_code")).toString().trimmed().toUpper();
const QJsonValue population = city.value(QStringLiteral("population"));
if (population.isDouble())
{
candidate.population = qRound64(population.toDouble());
}
candidates.append(candidate); candidates.append(candidate);
} }
return candidates; return candidates;
@@ -159,6 +137,16 @@ WeatherManager::WeatherManager()
{ {
m_timeoutTimer.setSingleShot(true); m_timeoutTimer.setSingleShot(true);
QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() { QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() {
if (m_ipLocationAttemptActive)
{
Logger::warning(QStringLiteral("Weather IP location provider timed out: index=%1")
.arg(m_ipLocationProviderIndex));
m_ipLocationAttemptActive = false;
clearReply();
startNextIpLocationRequest();
return;
}
if (m_locationTestOnly) if (m_locationTestOnly)
{ {
finishLocationTestWithError(QStringLiteral("城市测试超时,请稍后再试。")); finishLocationTestWithError(QStringLiteral("城市测试超时,请稍后再试。"));
@@ -293,6 +281,9 @@ void WeatherManager::cancel()
m_locationTestOnly = false; m_locationTestOnly = false;
m_locationCandidates.clear(); m_locationCandidates.clear();
m_hasLocationAmbiguity = false; m_hasLocationAmbiguity = false;
m_locationSearch = {};
m_ipLocationAttemptActive = false;
m_ipLocationProviderIndex = -1;
clearReply(); clearReply();
m_busy = false; m_busy = false;
} }
@@ -302,13 +293,23 @@ void WeatherManager::startGeocodingRequest(const QString &cityName, WeatherLocat
m_location = {}; m_location = {};
m_location.source = source; m_location.source = source;
m_locationTestOnly = locationTestOnly; m_locationTestOnly = locationTestOnly;
m_locationSearch = WeatherLocationResolver::resolveSearch(cityName);
if (m_locationSearch.queryName.trimmed().isEmpty())
{
finishGeocodingWithError(QStringLiteral("没有识别到有效城市名称。"));
return;
}
QUrl url(QStringLiteral("https://geocoding-api.open-meteo.com/v1/search")); QUrl url(QStringLiteral("https://geocoding-api.open-meteo.com/v1/search"));
QUrlQuery query; QUrlQuery query;
query.addQueryItem(QStringLiteral("name"), cityName); query.addQueryItem(QStringLiteral("name"), m_locationSearch.queryName);
query.addQueryItem(QStringLiteral("count"), QString::number(GeocodingCandidateCount)); query.addQueryItem(QStringLiteral("count"), QString::number(GeocodingCandidateCount));
query.addQueryItem(QStringLiteral("language"), m_config.language); query.addQueryItem(QStringLiteral("language"), m_config.language);
query.addQueryItem(QStringLiteral("format"), QStringLiteral("json")); query.addQueryItem(QStringLiteral("format"), QStringLiteral("json"));
if (!m_locationSearch.countryCode.isEmpty())
{
query.addQueryItem(QStringLiteral("countryCode"), m_locationSearch.countryCode);
}
url.setQuery(query); url.setQuery(query);
startRequest(url, [this](QNetworkReply *reply) { startRequest(url, [this](QNetworkReply *reply) {
@@ -322,11 +323,32 @@ void WeatherManager::startIpLocationRequest()
m_location.source = WeatherLocationSource::IpFallback; m_location.source = WeatherLocationSource::IpFallback;
m_locationCandidates.clear(); m_locationCandidates.clear();
m_hasLocationAmbiguity = false; m_hasLocationAmbiguity = false;
m_ipLocationProviderIndex = -1;
m_ipLocationAttemptActive = false;
startNextIpLocationRequest();
}
QUrl url(QStringLiteral("https://ipapi.co/json/")); void WeatherManager::startNextIpLocationRequest()
startRequest(url, [this](QNetworkReply *reply) { {
finishIpLocationRequest(reply); ++m_ipLocationProviderIndex;
}); if (m_ipLocationProviderIndex >= IpLocationProviderCount)
{
m_ipLocationAttemptActive = false;
finishWithError(QStringLiteral("公网 IP 定位服务暂不可用,请在设置页填写默认城市后重试。"));
return;
}
const WeatherIpLocationProvider provider = m_ipLocationProviderIndex == 0
? WeatherIpLocationProvider::IpApiCo
: WeatherIpLocationProvider::IpWhoIs;
const QUrl url(provider == WeatherIpLocationProvider::IpApiCo
? QStringLiteral("https://ipapi.co/json/")
: QStringLiteral("https://ipwho.is/"));
m_ipLocationAttemptActive = true;
startRequest(url, [this, provider](QNetworkReply *reply) {
finishIpLocationRequest(reply, provider);
}, qMin(m_config.timeoutMs, IpLocationAttemptTimeoutMs));
} }
void WeatherManager::startForecastRequest() void WeatherManager::startForecastRequest()
@@ -352,7 +374,10 @@ void WeatherManager::finishGeocodingRequest(QNetworkReply *reply)
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
finishGeocodingWithError(QStringLiteral("城市解析失败:%1。").arg(reply->errorString())); Logger::warning(QStringLiteral("Weather geocoding request failed: networkError=%1 http=%2")
.arg(static_cast<int>(reply->error()))
.arg(httpStatus));
finishGeocodingWithError(QStringLiteral("城市解析服务暂不可用,请稍后再试。"));
return; return;
} }
@@ -365,14 +390,16 @@ void WeatherManager::finishGeocodingRequest(QNetworkReply *reply)
} }
const QJsonArray results = document.object().value(QStringLiteral("results")).toArray(); const QJsonArray results = document.object().value(QStringLiteral("results")).toArray();
m_locationCandidates = candidatesFromGeocodingResults(results); m_locationCandidates = WeatherLocationResolver::selectRelevantCandidates(
candidatesFromGeocodingResults(results),
m_locationSearch);
if (m_locationCandidates.isEmpty()) if (m_locationCandidates.isEmpty())
{ {
finishGeocodingWithError(QStringLiteral("没有找到匹配的城市。")); finishGeocodingWithError(QStringLiteral("没有找到匹配的城市。"));
return; return;
} }
m_hasLocationAmbiguity = hasCandidateAmbiguity(m_locationCandidates); m_hasLocationAmbiguity = WeatherLocationResolver::hasAmbiguity(m_locationCandidates);
m_location = locationFromCandidate(m_locationCandidates.first(), m_location.source); m_location = locationFromCandidate(m_locationCandidates.first(), m_location.source);
if (m_locationTestOnly) if (m_locationTestOnly)
@@ -391,38 +418,33 @@ void WeatherManager::finishGeocodingRequest(QNetworkReply *reply)
startForecastRequest(); startForecastRequest();
} }
void WeatherManager::finishIpLocationRequest(QNetworkReply *reply) void WeatherManager::finishIpLocationRequest(QNetworkReply *reply, WeatherIpLocationProvider provider)
{ {
m_ipLocationAttemptActive = false;
const QByteArray body = reply->readAll(); const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
finishWithError(QStringLiteral("公网 IP 定位失败:%1。").arg(reply->errorString())); Logger::warning(QStringLiteral("Weather IP location request failed: provider=%1 networkError=%2 http=%3")
.arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois"))
.arg(static_cast<int>(reply->error()))
.arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
startNextIpLocationRequest();
return; return;
} }
QJsonParseError parseError; WeatherLocation location;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); if (!WeatherLocationResolver::parseIpLocationResponse(body, provider, &location))
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
finishWithError(QStringLiteral("公网 IP 定位结果格式无效。")); Logger::warning(QStringLiteral("Weather IP location response was unusable: provider=%1")
.arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois")));
startNextIpLocationRequest();
return; return;
} }
const QJsonObject root = document.object(); m_location = location;
const bool hasLatitude = jsonDouble(root, QStringLiteral("latitude"), &m_location.latitude); m_ipLocationProviderIndex = -1;
const bool hasLongitude = jsonDouble(root, QStringLiteral("longitude"), &m_location.longitude); Logger::info(QStringLiteral("Weather IP location succeeded: provider=%1")
m_location.cityName = root.value(QStringLiteral("city")).toString(); .arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois")));
m_location.adminName = root.value(QStringLiteral("region")).toString();
m_location.countryName = root.value(QStringLiteral("country_name")).toString();
m_location.timezone = root.value(QStringLiteral("timezone")).toString();
m_location.source = WeatherLocationSource::IpFallback;
if (!hasLatitude || !hasLongitude || m_location.cityName.trimmed().isEmpty())
{
finishWithError(QStringLiteral("公网 IP 定位没有返回有效城市或经纬度。"));
return;
}
Logger::info(QStringLiteral("Weather IP location succeeded."));
startForecastRequest(); startForecastRequest();
} }
@@ -431,7 +453,10 @@ void WeatherManager::finishForecastRequest(QNetworkReply *reply)
const QByteArray body = reply->readAll(); const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
finishWithError(QStringLiteral("天气数据请求失败:%1。").arg(reply->errorString())); Logger::warning(QStringLiteral("Weather forecast request failed: networkError=%1 http=%2")
.arg(static_cast<int>(reply->error()))
.arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
finishWithError(QStringLiteral("天气数据服务暂不可用,请稍后再试。"));
return; return;
} }
@@ -511,7 +536,10 @@ void WeatherManager::finishForecastRequest(QNetworkReply *reply)
finishWithReport(report); finishWithReport(report);
} }
void WeatherManager::startRequest(const QUrl &url, std::function<void(QNetworkReply *)> finishedHandler) void WeatherManager::startRequest(
const QUrl &url,
std::function<void(QNetworkReply *)> finishedHandler,
int timeoutMs)
{ {
clearReply(); clearReply();
@@ -538,11 +566,13 @@ void WeatherManager::startRequest(const QUrl &url, std::function<void(QNetworkRe
} }
}); });
m_timeoutTimer.start(m_config.timeoutMs); m_timeoutTimer.start(timeoutMs > 0 ? timeoutMs : m_config.timeoutMs);
} }
void WeatherManager::finishWithError(const QString &message) void WeatherManager::finishWithError(const QString &message)
{ {
m_ipLocationAttemptActive = false;
m_ipLocationProviderIndex = -1;
clearReply(); clearReply();
WeatherQueryResult result; WeatherQueryResult result;
result.success = false; result.success = false;
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "WeatherParser.h" #include "WeatherParser.h"
#include "WeatherLocationResolver.h"
#include "WeatherSummaryFormatter.h" #include "WeatherSummaryFormatter.h"
#include "WeatherTypes.h" #include "WeatherTypes.h"
@@ -32,11 +33,15 @@ public:
private: private:
void startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly); void startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly);
void startIpLocationRequest(); void startIpLocationRequest();
void startNextIpLocationRequest();
void startForecastRequest(); void startForecastRequest();
void finishGeocodingRequest(QNetworkReply *reply); void finishGeocodingRequest(QNetworkReply *reply);
void finishIpLocationRequest(QNetworkReply *reply); void finishIpLocationRequest(QNetworkReply *reply, WeatherIpLocationProvider provider);
void finishForecastRequest(QNetworkReply *reply); void finishForecastRequest(QNetworkReply *reply);
void startRequest(const QUrl &url, std::function<void(QNetworkReply *)> finishedHandler); void startRequest(
const QUrl &url,
std::function<void(QNetworkReply *)> finishedHandler,
int timeoutMs = -1);
void finishGeocodingWithError(const QString &message); void finishGeocodingWithError(const QString &message);
void finishWithError(const QString &message); void finishWithError(const QString &message);
void finishLocationTestWithError(const QString &message); void finishLocationTestWithError(const QString &message);
@@ -55,9 +60,12 @@ private:
LocationTestCallback m_locationTestCallback; LocationTestCallback m_locationTestCallback;
WeatherConfig m_config; WeatherConfig m_config;
WeatherQuery m_query; WeatherQuery m_query;
WeatherLocationSearch m_locationSearch;
WeatherLocation m_location; WeatherLocation m_location;
QVector<WeatherLocationCandidate> m_locationCandidates; QVector<WeatherLocationCandidate> m_locationCandidates;
bool m_locationTestOnly = false; bool m_locationTestOnly = false;
bool m_hasLocationAmbiguity = false; bool m_hasLocationAmbiguity = false;
bool m_ipLocationAttemptActive = false;
int m_ipLocationProviderIndex = -1;
bool m_busy = false; bool m_busy = false;
}; };
+4 -3
View File
@@ -64,8 +64,8 @@ QString ambiguityPrefix(const WeatherReport &report)
} }
QStringList otherCandidates; QStringList otherCandidates;
const int maxOtherCandidateCount = qMin(3, report.locationCandidates.size() - 1); const qsizetype candidateLimit = qMin<qsizetype>(4, report.locationCandidates.size());
for (int index = 1; index <= maxOtherCandidateCount; ++index) for (qsizetype index = 1; index < candidateLimit; ++index)
{ {
otherCandidates.append(candidateDisplayName(report.locationCandidates.at(index))); otherCandidates.append(candidateDisplayName(report.locationCandidates.at(index)));
} }
@@ -76,7 +76,8 @@ QString ambiguityPrefix(const WeatherReport &report)
} }
return QStringLiteral("可能存在同名城市,当前使用:%1。其他候选包括:%2。\n") return QStringLiteral("可能存在同名城市,当前使用:%1。其他候选包括:%2。\n")
.arg(locationDisplayName(report.location), otherCandidates.join(QStringLiteral(";"))); .arg(locationDisplayName(report.location))
.arg(otherCandidates.join(QStringLiteral(";")));
} }
QString temperatureText(double value) QString temperatureText(double value)
+17
View File
@@ -6,6 +6,7 @@
#include <QDateTime> #include <QDateTime>
#include <QString> #include <QString>
#include <QVector> #include <QVector>
#include <QtGlobal>
enum class WeatherLocationSource enum class WeatherLocationSource
{ {
@@ -21,6 +22,12 @@ enum class WeatherQueryKind
Range, Range,
}; };
enum class WeatherIpLocationProvider
{
IpApiCo,
IpWhoIs,
};
struct WeatherQuery struct WeatherQuery
{ {
QString originalText; QString originalText;
@@ -32,6 +39,13 @@ struct WeatherQuery
QString unsupportedReason; QString unsupportedReason;
}; };
struct WeatherLocationSearch
{
QString originalName;
QString queryName;
QString countryCode;
};
struct WeatherLocation struct WeatherLocation
{ {
QString cityName; QString cityName;
@@ -51,6 +65,9 @@ struct WeatherLocationCandidate
QString timezone; QString timezone;
double latitude = 0.0; double latitude = 0.0;
double longitude = 0.0; double longitude = 0.0;
QString countryCode;
QString featureCode;
qint64 population = -1;
}; };
struct WeatherCurrent struct WeatherCurrent
+111 -3
View File
@@ -4,6 +4,7 @@ param(
[string]$Version = "", [string]$Version = "",
[string]$OutputRoot = "dist", [string]$OutputRoot = "dist",
[string]$WindeployQtPath = "", [string]$WindeployQtPath = "",
[string]$MsvcRuntimeDir = "",
[switch]$NoZip, [switch]$NoZip,
[switch]$BuildInstaller, [switch]$BuildInstaller,
[string]$InnoCompilerPath = "", [string]$InnoCompilerPath = "",
@@ -44,7 +45,7 @@ function Read-ProjectVersion {
return $match.Groups[1].Value return $match.Groups[1].Value
} }
return "0.1.0" return "0.2.0"
} }
function Resolve-WindeployQt { function Resolve-WindeployQt {
@@ -69,6 +70,95 @@ function Resolve-WindeployQt {
throw "windeployqt.exe was not found. Add the Qt bin directory to PATH or pass -WindeployQtPath." throw "windeployqt.exe was not found. Add the Qt bin directory to PATH or pass -WindeployQtPath."
} }
function Test-MsvcRuntimeDirectory {
param(
[string]$Directory
)
$requiredFiles = @(
"msvcp140.dll",
"msvcp140_1.dll",
"msvcp140_2.dll",
"vcruntime140.dll",
"vcruntime140_1.dll"
)
foreach ($fileName in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $Directory $fileName) -PathType Leaf)) {
return $false
}
}
return $true
}
function Resolve-MsvcRuntimeDirectory {
param(
[string]$ExplicitPath
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
$resolved = Resolve-FullPath -Path $ExplicitPath -BasePath (Get-Location)
if ((Test-Path -LiteralPath $resolved -PathType Container) -and
(Test-MsvcRuntimeDirectory -Directory $resolved)) {
return $resolved
}
throw "The x64 MSVC runtime directory is incomplete: $resolved"
}
$x64Roots = @()
if (-not [string]::IsNullOrWhiteSpace($env:VCToolsRedistDir)) {
$x64Roots += Join-Path $env:VCToolsRedistDir "x64"
}
if (-not [string]::IsNullOrWhiteSpace($env:VCINSTALLDIR)) {
$redistRoot = Join-Path $env:VCINSTALLDIR "Redist\MSVC"
if (Test-Path -LiteralPath $redistRoot -PathType Container) {
$versionDirectories = Get-ChildItem -LiteralPath $redistRoot -Directory |
Sort-Object Name -Descending
foreach ($versionDirectory in $versionDirectories) {
$x64Roots += Join-Path $versionDirectory.FullName "x64"
}
}
}
foreach ($x64Root in $x64Roots) {
if (-not (Test-Path -LiteralPath $x64Root -PathType Container)) {
continue
}
$runtimeDirectories = Get-ChildItem -LiteralPath $x64Root -Directory -Filter "Microsoft.VC*.CRT" |
Sort-Object Name -Descending
foreach ($runtimeDirectory in $runtimeDirectories) {
if (Test-MsvcRuntimeDirectory -Directory $runtimeDirectory.FullName) {
return $runtimeDirectory.FullName
}
}
}
throw "The x64 MSVC runtime was not found. Run from a Visual Studio developer shell or pass -MsvcRuntimeDir."
}
function Copy-MsvcRuntime {
param(
[string]$SourceDirectory,
[string]$DestinationDirectory
)
$runtimeFiles = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Filter "*.dll")
if ($runtimeFiles.Count -eq 0) {
throw "No MSVC runtime DLLs were found in: $SourceDirectory"
}
foreach ($runtimeFile in $runtimeFiles) {
Copy-Item -LiteralPath $runtimeFile.FullName -Destination $DestinationDirectory -Force
}
if (-not (Test-MsvcRuntimeDirectory -Directory $DestinationDirectory)) {
throw "MSVC runtime deployment validation failed: $DestinationDirectory"
}
}
function Resolve-InnoCompiler { function Resolve-InnoCompiler {
param( param(
[string]$ExplicitPath [string]$ExplicitPath
@@ -203,6 +293,12 @@ Assert-RequiredPath -Path $licensePath -Description "LICENSE"
Assert-RequiredPath -Path $readmePath -Description "README.md" Assert-RequiredPath -Path $readmePath -Description "README.md"
$windeployQt = Resolve-WindeployQt -ExplicitPath $WindeployQtPath $windeployQt = Resolve-WindeployQt -ExplicitPath $WindeployQtPath
$isMsvcDeployment = (-not [string]::IsNullOrWhiteSpace($MsvcRuntimeDir)) -or
($windeployQt -match '(?i)[\\/]msvc[^\\/]*[\\/]bin[\\/]windeployqt\.exe$')
$resolvedMsvcRuntimeDir = ""
if ($isMsvcDeployment) {
$resolvedMsvcRuntimeDir = Resolve-MsvcRuntimeDirectory -ExplicitPath $MsvcRuntimeDir
}
Assert-ChildPath -Path $packageRoot -ParentPath $resolvedOutputRoot -Description "Release package directory" Assert-ChildPath -Path $packageRoot -ParentPath $resolvedOutputRoot -Description "Release package directory"
if (Test-Path -LiteralPath $packageRoot) { if (Test-Path -LiteralPath $packageRoot) {
@@ -218,7 +314,19 @@ Copy-Item -LiteralPath $licensePath -Destination (Join-Path $packageRoot "LICENS
Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -Force Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -Force
Write-Host "Running windeployqt: $windeployQt" Write-Host "Running windeployqt: $windeployQt"
Invoke-CheckedProcess -FilePath $windeployQt -Arguments @("--release", "--compiler-runtime", $targetExePath) $windeployArguments = @("--release")
if ($isMsvcDeployment) {
$windeployArguments += "--no-compiler-runtime"
} else {
$windeployArguments += "--compiler-runtime"
}
$windeployArguments += $targetExePath
Invoke-CheckedProcess -FilePath $windeployQt -Arguments $windeployArguments
if ($isMsvcDeployment) {
Write-Host "Deploying app-local MSVC runtime: $resolvedMsvcRuntimeDir"
Copy-MsvcRuntime -SourceDirectory $resolvedMsvcRuntimeDir -DestinationDirectory $packageRoot
}
$manifestPath = Join-Path $packageRoot "package_manifest.txt" $manifestPath = Join-Path $packageRoot "package_manifest.txt"
@( @(
@@ -226,7 +334,7 @@ $manifestPath = Join-Path $packageRoot "package_manifest.txt"
"Version: $Version", "Version: $Version",
"CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))", "CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))",
"SourceExe: $resolvedExePath", "SourceExe: $resolvedExePath",
"Includes: QtDesktopPet.exe, Qt runtime, character/icon/sound resources, LICENSE, README.md", "Includes: QtDesktopPet.exe, Qt/compiler runtime, character/icon/sound resources, LICENSE, README.md",
"Excludes: tools, docs, reports, build, dist, .git" "Excludes: tools, docs, reports, build, dist, .git"
) | Set-Content -LiteralPath $manifestPath -Encoding UTF8 ) | Set-Content -LiteralPath $manifestPath -Encoding UTF8