Compare commits
1 Commits
90262aaf6d
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5e26c361 |
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` 可作为预发布版本发布;已知限制为联网模式未实测、安装器未数字签名,公开分发时应明确说明。
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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您确定要继续吗?
|
||||||
@@ -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.
|
||||||
@@ -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 许可证使用。
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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("晴"));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user