From 04263dff4e0f243194c43e395657091bd8190d23 Mon Sep 17 00:00:00 2001 From: ShaoHua <345265198@qqcom> Date: Wed, 8 Apr 2026 19:59:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E4=BA=8B=E9=A1=B9=E6=A8=A1=E5=9D=97=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E4=B8=8E=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .trae/coordination/README.md | 18 - .trae/coordination/ownership.md | 13 - .trae/coordination/shared-files.md | 11 - .trae/rules/commenting.md | 33 -- .trae/rules/documentation_sync.md | 34 -- .../rules/parallel_solo_conflict_avoidance.md | 99 ----- .trae/rules/task_breakdown.md | 50 --- README.md | 24 +- docs/manual/代码规范文档.md | 22 +- docs/manual/技术设计文档.md | 49 ++- docs/manual/版本记录.md | 9 +- .../DynamicApiSwaggerDocumentFilter.cs | 400 ++++++++++++++++++ .../Hua.Todo.Application.csproj | 1 + .../Views/MainWindow.axaml.cs | 12 +- src/Hua.Todo.Host/Program.cs | 6 +- src/Hua.Todo.Maui/Hua.Todo.Maui.csproj | 19 +- src/Hua.Todo.Maui/MauiProgram.cs | 17 +- .../Platforms/Windows/MainPage.Windows.cs | 49 +++ .../Windows/WebView2RuntimeDetector.cs | 99 +++++ src/Hua.Todo.Maui/README.md | 25 +- src/Hua.Todo.Maui/Services/AppMetadata.cs | 2 +- .../Services/EmbeddedWebServerService.cs | 25 +- src/Hua.Todo.Maui/Views/MainPage.xaml.cs | 9 + src/Hua.Todo.Maui/appsettings.json | 4 +- src/Hua.Todo.Maui/setup.iss | 13 +- src/Hua.Todo.Web/src/api/client.ts | 7 + src/Hua.Todo.Web/src/api/cloudClient.ts | 17 + start-dev.ps1 | 49 +++ start-host.ps1 | 90 ++++ 30 files changed, 888 insertions(+), 320 deletions(-) delete mode 100644 .trae/coordination/README.md delete mode 100644 .trae/coordination/ownership.md delete mode 100644 .trae/coordination/shared-files.md delete mode 100644 .trae/rules/commenting.md delete mode 100644 .trae/rules/documentation_sync.md delete mode 100644 .trae/rules/parallel_solo_conflict_avoidance.md delete mode 100644 .trae/rules/task_breakdown.md create mode 100644 src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs create mode 100644 src/Hua.Todo.Maui/Platforms/Windows/WebView2RuntimeDetector.cs create mode 100644 start-dev.ps1 create mode 100644 start-host.ps1 diff --git a/.gitignore b/.gitignore index 4470b42..2332f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -368,3 +368,5 @@ FodyWeavers.xsd /src/Hua.Todo.Host/Hua.Todo.db /src/Hua.Todo.Host/Hua.Todo.db-shm /src/Hua.Todo.Host/Hua.Todo.db-wal +/.artifacts/buildcheck +/.trae diff --git a/.trae/coordination/README.md b/.trae/coordination/README.md deleted file mode 100644 index a8d0ad4..0000000 --- a/.trae/coordination/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# 协调目录(并行 solo 专用) - -该目录用于解决两类问题: - -- 文件冲突:多窗口并行时明确“谁是 Writer”,其他人不直接改同一文件 -- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进 - -## 目录约定 - -- `ownership.md`:文件/目录所有权登记(Writer 表) -- `shared-files.md`:本阶段共享文件清单(由 Integrator 维护) -- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘) -- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件) - -## 使用规则 - -- 所有权与共享文件清单优先使用“仓库相对路径” -- 禁止记录或提交构建产物目录中的文件路径(如 `bin/`、`obj/`、`node_modules/`、`dist/` 等) diff --git a/.trae/coordination/ownership.md b/.trae/coordination/ownership.md deleted file mode 100644 index 278c564..0000000 --- a/.trae/coordination/ownership.md +++ /dev/null @@ -1,13 +0,0 @@ -# 文件/目录所有权(Writer)登记 - -规则: - -- 同一时段内,同一个文件只能有一个 Writer -- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘 -- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码 - -## 当前所有权 - -| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 | -|---|---|---|---| -| `src\\` | `` | `` | `共享:是/否` | diff --git a/.trae/coordination/shared-files.md b/.trae/coordination/shared-files.md deleted file mode 100644 index 9f38fb7..0000000 --- a/.trae/coordination/shared-files.md +++ /dev/null @@ -1,11 +0,0 @@ -# 本阶段共享文件清单(Integrator 维护) - -规则: - -- 本文件只由 Integrator 修改,避免反复冲突 -- 清单内每条必须是“仓库相对路径”,并说明为什么共享(入口/协议/配置/依赖锁等) -- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator - -## 共享文件 - -- `src\\`:`` diff --git a/.trae/rules/commenting.md b/.trae/rules/commenting.md deleted file mode 100644 index 9c3139c..0000000 --- a/.trae/rules/commenting.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -alwaysApply: true -description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。 ---- - -# 注释规范(必须遵守) - -## 通用 - -- 新增或修改的代码必须包含足够注释,使“不了解该模块的人”也能理解其职责、边界与关键决策。 -- 优先使用 **XML 文档注释**(`///`),而不是随意的行内注释。 -- 不允许无意义注释(例如“初始化变量”“进入方法”)。注释必须解释“为什么/约束/边界/副作用”。 -- 不允许出现“TODO/FIXME”但无上下文或无处理方案的注释。 - -## C#(.NET / MAUI) - -- 所有 `public` / `protected` 的 **类、接口、方法、属性** 必须提供 XML 文档注释,至少包含: - - `summary`:一句话说明用途 - - 对关键参数/返回值:`param` / `returns` - - 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务) -- 对 **跨平台逻辑**: - - 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。 - - 平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。 -- 对 **异步/后台任务**: - - 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。 -- 对 **安全/隐私**: - - 禁止在日志或注释中输出密钥、Token、用户隐私信息。 - -## TypeScript / Vue(前端) - -- 对导出的函数/类型必须有注释,解释用途与输入输出。 -- 对“与后端/MAUI 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。 - diff --git a/.trae/rules/documentation_sync.md b/.trae/rules/documentation_sync.md deleted file mode 100644 index 9d7b666..0000000 --- a/.trae/rules/documentation_sync.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -alwaysApply: true -description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。 ---- - -# 文档同步规范(必须遵守) - -## 通用原则 - -- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。 -- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。 -- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。 -- **协作友好(局部修改)**:当并行处理多个任务/需求时,更新文档应尽量只修改与本任务直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。 - -## 更新范围 - -- **README.md**: - - 如果变更涉及核心功能点(Features)、安装步骤(Installation)、快速开始(Quick Start)或 API 端点(API Endpoints),必须同步更新。 - - 变更涉及技术栈调整或项目结构变化时需更新。 -- **docs/ 目录文档**: - - **接口变更**:若修改了 API,需同步更新 [技术设计文档](docs/技术设计文档.md) 中的接口部分。 - - **功能新增/调整**:需在 [产品需求文档](docs/产品需求文档.md) 和 [技术栈与模块](docs/技术栈与模块.md) 中体现。 - - **架构/模式变更**:需更新 [技术设计文档](docs/技术设计文档.md)。 - - **代码规范**:若引入了新的编码模式或工具,需更新 [代码规范文档](docs/代码规范文档.md)。 - - **版本记录**:所有非琐碎的变更必须在 [版本记录.md](docs/版本记录.md) 中添加记录。 - -## 检查清单 - -1. [ ] 是否有新增的 API 端点?(更新 README 和技术设计文档) -2. [ ] 是否修改了现有的业务逻辑或数据结构?(更新技术设计文档) -3. [ ] 是否有新增的功能模块?(更新产品需求文档和技术栈说明) -4. [ ] 是否调整了开发环境或依赖?(更新 README) -5. [ ] 是否在 [版本记录.md](docs/版本记录.md) 中记录了本次变更? -6. [ ] 文档变更是否保持“局部修改”,只影响与本任务相关的段落/小节?(避免无关重排/改写) diff --git a/.trae/rules/parallel_solo_conflict_avoidance.md b/.trae/rules/parallel_solo_conflict_avoidance.md deleted file mode 100644 index ad64aac..0000000 --- a/.trae/rules/parallel_solo_conflict_avoidance.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -alwaysApply: false -description: ---- -# 并行 solo 窗口冲突规约(必须遵守) - -## 适用范围 - -- 当同一个版本/需求被拆分为多个并行任务,并由多个 solo 窗口同时推进时适用。 -- 目标是同时降低两类风险: - - **文件冲突**:多人同时改同一文件/相邻行导致冲突。 - - **编译区间冲突**:A 窗口引入的未完成变更破坏编译,阻塞 B 窗口集成与验证。 - -## 核心原则 - -- **先声明后修改**:任何代码改动前,先在任务文档中声明“触碰文件清单(Touch List)”与“共享文件策略”。 -- **文件所有权唯一**:同一时段内,一个文件只能被一个窗口作为“写入者(Writer)”修改。 -- **共享文件单点修改**:涉及高耦合/共享入口的改动,集中到一个“集成窗口(Integrator)”完成,其他窗口只做准备工作(新文件/独立模块/文档/测试)。 -- **绿线优先(可编译)**:任何可落盘、可合入的变更必须保持可编译;临时状态必须通过“隔离手段”而不是破坏编译来实现。 - -## Touch List(触碰文件清单) - -- 每个并行任务 md 必须在开头包含一个明确的 Touch List,至少包含: - - 新增/修改/删除的文件路径(精确到文件,必须写“准确目录”) - - 预期修改类型(新增/小改/重构/接口变更/配置变更) - - 是否为共享文件(是/否) -- Touch List 必须保持可检索与可更新:变更范围扩大时,必须先更新 Touch List 再改代码。 - -### 目录书写要求(必须遵守) - -- Touch List 内每一条必须使用以下两种格式之一: - - **仓库相对路径(推荐)**:`src\\` - - **绝对路径(可选)**:`\src\\`(`` 为本机仓库根目录) -- Touch List 禁止包含构建产物与临时目录中的文件(这些文件不应被手工修改,且极易产生冲突),包括但不限于: - - `**\bin\**`、`**\obj\**` - - `**\node_modules\**` - - `**\.vite\**`、`**\dist\**` - -### Touch List 模板(复制即可用) - -- Touch List: - - `src\\`(共享:否|Writer:本窗口) - - `src\\`(共享:是|Writer:<窗口名>) - - `docs\`(共享:是/否|Writer:<窗口名>) - - `.trae\`(共享:是|Writer:<窗口名>) - -## 文件所有权与共享文件策略 - -- **默认规则**:Touch List 中标记为“共享文件”的条目,必须指定唯一 Writer。 -- **Writer 约束**: - - 非 Writer 窗口不得编辑该共享文件(包括格式化、重排 import、无关重构)。 - - 需要对共享文件提出修改时,非 Writer 只能提供“差异建议”(文字说明/伪代码/小片段)交给 Writer 落盘。 -- **共享文件判定(满足其一即为共享)**: - - 项目入口/启动逻辑、依赖注入注册、全局路由/导航、公共配置、公共协议与 DTO、公共组件/样式、跨模块公共工具 - - 解决方案/项目文件(如 `.sln`、`.csproj`)、锁文件、全局配置文件(如 `appsettings*`、构建脚本) - -## 协调目录(必须遵守) - -- 为了让“文件冲突”和“编译中途状态”可操作、可对齐,仓库内必须固定保留一个专用协调目录: - - `.trae\coordination\` -- 该目录只用于协作对齐,不承载业务实现代码;多人可在不同文件中写入,避免互相踩踏。 -- 并行推进时必须使用该目录中的文件记录“谁在改什么”和“中途状态怎么保证不破坏编译”: - - `.trae\coordination\ownership.md`:文件/目录所有权(Writer)登记表 - - `.trae\coordination\shared-files.md`:本阶段共享文件清单(只有 Integrator 维护) - - `.trae\coordination\handoff\`:非 Writer 提交的差异建议/交接说明(Integrator 落盘) - - `.trae\coordination\wip\`:编译中途状态说明(为什么需要隔离、如何保证绿线、何时收敛) - -## 目录分区与低冲突写法 - -- 优先通过“新增文件”完成并行开发,减少在同一文件内的交错修改。 -- 需要扩展既有逻辑时,优先选择低冲突策略: - - C#:新增类/partial 文件、扩展方法、接口实现分文件、平台目录分离 - - TypeScript/Vue:新增模块/组件文件,避免在同一大文件内做多处改动 -- 禁止在非必要情况下对共享文件做纯格式化、纯重排或无收益重构(这些改动高度易冲突且难以 review)。 - -## 编译绿线(避免编译区间冲突) - -- **不得提交/合入破坏编译的变更**:包括缺失类型、未实现接口、引用不存在、配置缺项导致启动失败等。 -- **允许的临时隔离手段(按优先级)**: - 1. 新功能先放在新文件/新类中,不在入口路径上启用 - 2. 通过显式开关控制启用(配置/运行时开关),默认关闭 - 3. 通过依赖注入分支注册或特性开关隔离,默认不触发 -- 当必须进行接口演进时,采用“双写/兼容期”策略: - - 先新增(保持旧接口可用)→ 再迁移调用方 → 最后清理旧接口 - -## 合入顺序与集成职责 - -- 每个并行阶段必须明确一个集成窗口(Integrator),负责: - - 处理共享文件的实际落盘与冲突消解 - - 保持主干/集成分支持续可编译、可运行 -- 其他窗口提交的成果应尽量以“新增文件 + 最小修改点”的方式交付,降低集成成本。 - -## 最小检查清单 - -1. [ ] 每个任务 md 是否已写 Touch List(精确到文件)? -2. [ ] Touch List 中的共享文件是否指定了唯一 Writer? -3. [ ] 是否避免了对共享文件的无意义格式化/重排? -4. [ ] 当前改动是否保持可编译(绿线)? -5. [ ] 若涉及接口演进,是否采用兼容期策略而非一次性破坏式变更? diff --git a/.trae/rules/task_breakdown.md b/.trae/rules/task_breakdown.md deleted file mode 100644 index 38d701e..0000000 --- a/.trae/rules/task_breakdown.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -alwaysApply: false ---- -# 任务拆分输出规范(必须遵守) - -## 适用时机 - -- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出任务拆分文档,再开始写代码或改配置。 - -## 输出要求 - -- **先读完所有相关文档**:包括但不限于 `docs/`、`docs/project/` 下与本次需求相关的内容。 -- **先写任务拆分,再动手实现**:任务拆分产出是后续执行的入口与对齐依据。 -- **新增一个专属文件夹**:在 `d:\Proj\6.Hua.Todo\docs\project` 下新建一个文件夹存放本次任务拆分文档。 - - 文件夹命名建议:`任务拆分-<主题>-<日期或版本>`(保持可检索、避免与既有文档冲突)。 -- **可并行的任务要拆成不同 md**:能同步执行(相互无依赖/弱依赖)的任务,必须拆到不同的 Markdown 文件中,便于并行推进与分工。 -- **文件必须带序号**:同一文件夹下的 md 文件按执行顺序编号,序号从小到大。 - - 文件名建议:`01-xxx.md`、`02-xxx.md`、`03-xxx.md`。 -- **每个任务文件至少包含**: - - 目标/范围(做什么、不做什么) - - 前置条件(依赖哪些结论/接口/文档) - - 验收标准(怎么判断完成,包含可执行的验证点) - - 风险与回滚(如有) -- **子任务完成后的标记要求**: - - 当任一子任务(例如 `01-*`/`02-*`/`03-*`)完成实现后,必须在对应版本的 `00-任务总览.md` 中同步标注“已完成”。 - - 同时必须维护一张“待验证表”(可用 Markdown 表格),对每个子任务给出“待验证/已验证”状态,避免实现完成但验收未闭环。 -- **并行冲突规避要求**:当任务会被分发到多个 solo 窗口并行推进时,每个任务文件必须额外包含: - - 触碰文件清单(Touch List,精确到文件) - - 共享文件策略(哪些是共享文件、唯一 Writer 是谁、如何与集成窗口对接) - - 编译绿线策略(如何确保阶段性交付不破坏编译) - -## 推荐结构(模板) - -- `00-总览.md` - - 背景与目标 - - 关键决策与约束 - - 并行分组说明(哪些文件可同步做) -- `01-<并行任务A>.md` -- `02-<并行任务B>.md` -- `03-<串行任务C>.md` - -## 最小检查清单 - -1. [ ] 是否确认已阅读完所有相关文档? -2. [ ] 是否在 `docs/project` 下新建了本次专属文件夹? -3. [ ] 是否产出 `00-总览.md`(或等价总览文件)? -4. [ ] 是否将可并行任务拆分为不同 md 文件? -5. [ ] 是否所有 md 文件都带有连续序号? -6. [ ] 并行任务是否为每个任务文件补充了 Touch List/共享文件策略/编译绿线策略? -7. [ ] 子任务完成后,是否在对应版本的 `00-任务总览.md` 标注“已完成”,并在“待验证表”里更新状态? diff --git a/README.md b/README.md index 0cc4fae..b287629 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ dotnet restore dotnet run ``` API 将在 `http://localhost:5173` 启动 +开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`) #### 3. 启动前端 Web ```bash @@ -47,6 +48,14 @@ npm run dev ``` 前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173` +#### 4. 启动 MAUI 客户端(Windows 三件套开发) +- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)` 与 `Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。 +- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI): + +```powershell +.\start-dev.ps1 +``` + ### Windows 交付产物(安装包) - 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj` 的 ``;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows//installer/`) @@ -88,12 +97,15 @@ Hua.Todo/ ``` ### API 端点 -- `GET /api/tasks` - 获取任务列表 -- `GET /api/tasks/{id}` - 获取单个任务 -- `POST /api/tasks` - 创建任务 -- `PUT /api/tasks/{id}` - 更新任务 -- `PATCH /api/tasks/{id}/complete` - 切换完成状态 -- `DELETE /api/tasks/{id}` - 删除任务 +- `GET /api/task` - 获取任务列表(默认:全部) +- `GET /api/task/active` - 获取未完成任务 +- `GET /api/task/completed` - 获取已完成任务 +- `GET /api/task/{id}` - 获取单个任务 +- `POST /api/task` - 创建任务 +- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位) +- `PATCH /api/task/{id}/toggle` - 切换完成状态 +- `DELETE /api/task/{id}` - 删除任务 +- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表 ## 🤝 交流与贡献 diff --git a/docs/manual/代码规范文档.md b/docs/manual/代码规范文档.md index 0498a51..6d31d48 100644 --- a/docs/manual/代码规范文档.md +++ b/docs/manual/代码规范文档.md @@ -314,7 +314,7 @@ const task = response.data as Task; // 避免 ```typescript // 使用 async/await 而非 Promise 链 async function getTasks(): Promise { - const response = await axios.get('/api/tasks'); + const response = await axios.get('/api/task'); return response.data; } @@ -457,7 +457,7 @@ export const useTaskStore = defineStore('tasks', () => { async function fetchTasks() { loading.value = true; try { - const response = await fetch('/api/tasks'); + const response = await fetch('/api/task'); tasks.value = await response.json(); } catch (err) { error.value = 'Failed to fetch tasks'; @@ -481,30 +481,30 @@ export const useTaskStore = defineStore('tasks', () => { ### 6.1 RESTful API 设计 ```csharp -// 使用名词复数形式 -[HttpGet("tasks")] +// 本项目的任务端点采用 Dynamic API 路由约定:/api/task(由中间件反射分发) +[HttpGet("task")] public async Task>> GetTasks() { } // 使用资源 ID -[HttpGet("tasks/{id}")] +[HttpGet("task/{id}")] public async Task> GetTask(int id) { } // 使用 HTTP 方法表示操作 -[HttpPost("tasks")] +[HttpPost("task")] public async Task> CreateTask(CreateTaskDto dto) { } -[HttpPut("tasks/{id}")] -public async Task> UpdateTask(int id, UpdateTaskDto dto) +[HttpPut("task")] +public async Task> UpdateTask(UpdateTaskDto dto) { } -[HttpDelete("tasks/{id}")] +[HttpDelete("task/{id}")] public async Task DeleteTask(int id) { } @@ -563,7 +563,7 @@ return BadRequest(new ApiResponse ``` feat(api): add task completion endpoint -- Add PATCH /api/tasks/{id}/complete endpoint +- Add PATCH /api/task/{id}/toggle endpoint - Update task service to handle completion logic - Add unit tests for completion functionality @@ -609,7 +609,7 @@ public class ApiIntegrationTests : IClassFixture> public async Task GetTasks_ReturnsSuccessAndCorrectContentType() { // Act - var response = await _client.GetAsync("/api/tasks"); + var response = await _client.GetAsync("/api/task"); // Assert response.EnsureSuccessStatusCode(); diff --git a/docs/manual/技术设计文档.md b/docs/manual/技术设计文档.md index 8359708..dd5ab89 100644 --- a/docs/manual/技术设计文档.md +++ b/docs/manual/技术设计文档.md @@ -173,22 +173,28 @@ Hua.Todo/ - WebView 容器管理 - 本地 HTTP 服务器启动 +**调试与接口文档**: +- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。 + **关键组件**: - `MauiProgram.cs`: 配置 MAUI 应用和依赖注入 - `App.xaml.cs`: 应用程序主入口 - `WebViewContainer`: 封装 WebView 控件 - 平台特定服务: 快捷键、通知等 -### 4.2 后端 API 项目 (Hua.Todo.Api) +### 4.2 后端 API 项目 (Hua.Todo.Host) **职责**: - 提供 RESTful API 接口 - 业务逻辑处理 - 数据访问和持久化 - 本地 HTTP 服务器托管 +**接口文档**: +- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。 + **关键组件**: -- `Controllers`: API 端点实现 -- `Services`: 业务逻辑服务 +- `CloudSync`: 云同步 Minimal APIs(`/auth`、`/tasks`、`/sync`、`/security`) +- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API(`/api/{service}/...`)对外暴露 - `Data`: 数据访问层和数据库上下文 - `Program.cs`: API 服务器配置和启动 @@ -220,33 +226,36 @@ Hua.Todo/ ## 5. HTTP API 设计 ### 5.1 API 基础配置 -- **基础 URL**: `http://localhost:5000/api` +- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`) +- **基础 URL(MAUI 内嵌模式)**: `{HostUrl}/api`(`HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`) - **数据格式**: JSON -- **认证方式**: 暂无(本地应用) +- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证) - **跨域配置**: 允许本地跨域请求 ### 5.2 API 端点设计 #### 任务管理 API ``` -GET /api/tasks # 获取任务列表 -GET /api/tasks/{id} # 获取单个任务 -POST /api/tasks # 创建任务 -PUT /api/tasks/{id} # 更新任务 -DELETE /api/tasks/{id} # 删除任务 -PATCH /api/tasks/{id}/complete # 标记任务完成 +GET /api/task # 获取任务列表(默认:全部) +GET /api/task/active # 获取未完成任务 +GET /api/task/completed # 获取已完成任务 +GET /api/task/{id} # 获取单个任务 +POST /api/task # 创建任务 +PUT /api/task # 更新任务(通过 Body 内的 id 定位) +DELETE /api/task/{id} # 删除任务 +PATCH /api/task/{id}/toggle # 切换完成状态 +GET /api/task/{parentTaskId}/subtasks # 获取子任务列表 ``` -#### 设置管理 API +#### 云同步 API(Host 模式) ``` -GET /api/settings # 获取设置 -PUT /api/settings # 更新设置 -``` - -#### 同步 API -``` -POST /api/sync/pull # 拉取远程数据 -POST /api/sync/push # 推送本地数据 +POST /auth/bootstrap # 初始化管理员(仅首次) +POST /auth/login # 登录 +POST /auth/step-up # 二次验证(提升权限) +GET /tasks/ # 获取云端任务(只读) +POST /sync/ # 推送/拉取合并同步 +GET /security/policy # 获取安全策略 +PUT /security/policy # 更新安全策略 ``` ## 6. 数据库设计 diff --git a/docs/manual/版本记录.md b/docs/manual/版本记录.md index 88a9ac0..82720c2 100644 --- a/docs/manual/版本记录.md +++ b/docs/manual/版本记录.md @@ -12,9 +12,16 @@ ### v1.2.0(开发中,2026-04-07) - **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。 -- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示)。 +- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。 +- **MAUI(Windows)内嵌 API 文档**:Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。 +- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。 +- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作“后端专用路径”排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。 +- **MAUI 多平台构建开关**:在 Windows 开发机上默认仅构建 Android + Windows 目标,避免 iOS/MacCatalyst 目标在非 macOS 环境触发运行时包缺失(NETSDK1082);在 macOS 上仍会包含 iOS/MacCatalyst 目标。 - **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`。 - **Windows 发布打包修复**:Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。 +- **Windows WebView2 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。 +- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。 +- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。 - **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。 ### v1.1.1 (2026-04-06) diff --git a/src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs b/src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs new file mode 100644 index 0000000..9ffbb2b --- /dev/null +++ b/src/Hua.Todo.Application/DynamicApi/Swagger/DynamicApiSwaggerDocumentFilter.cs @@ -0,0 +1,400 @@ +using System.Reflection; +using Hua.Todo.Application.Interfaces; +using Hua.Todo.Application.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Microsoft.OpenApi; + +namespace Hua.Todo.Application.DynamicApi.Swagger; + +/// +/// 为 Dynamic API(/api/{service}/...)补齐 OpenAPI 文档的过滤器。 +/// 说明:Dynamic API 通过中间件反射分发,不会被 ASP.NET Core 的 ApiExplorer 自动发现; +/// 因此需要在 Swagger 生成阶段手动把这些端点补充到 OpenAPI Paths 中。 +/// +public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter +{ + /// + /// 将 Dynamic API 端点追加到 。 + /// + /// 待输出的 OpenAPI 文档。 + /// Swagger 生成上下文(用于 Schema 生成与引用管理)。 + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var dynamicApiAssembly = typeof(IDynamicApiService).Assembly; + var serviceInterfaces = dynamicApiAssembly + .GetTypes() + .Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t)) + .Where(IsRemoteServiceEnabled) + .ToList(); + + foreach (var serviceInterface in serviceInterfaces) + { + var serviceSegment = GetServiceRouteSegment(serviceInterface); + if (string.IsNullOrWhiteSpace(serviceSegment)) + { + continue; + } + + AddServiceOperations(swaggerDoc, context, serviceInterface, serviceSegment); + } + } + + private static void AddServiceOperations( + OpenApiDocument swaggerDoc, + DocumentFilterContext context, + Type serviceInterface, + string serviceSegment) + { + var methods = serviceInterface + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(IsRemoteServiceEnabled) + .ToList(); + + foreach (var method in methods) + { + var httpMethod = GetHttpMethod(method); + if (string.IsNullOrWhiteSpace(httpMethod)) + { + continue; + } + + if (!TryGetRouteSuffix(serviceInterface, method, out var routeSuffix)) + { + continue; + } + + var fullPath = BuildFullPath(serviceSegment, routeSuffix); + if (!swaggerDoc.Paths.TryGetValue(fullPath, out var pathItem)) + { + pathItem = new OpenApiPathItem(); + swaggerDoc.Paths[fullPath] = pathItem; + } + + var operation = BuildOperation(context, serviceSegment, method, httpMethod, routeSuffix); + TrySetOperation(pathItem, httpMethod, operation); + } + } + + private static OpenApiOperation BuildOperation( + DocumentFilterContext context, + string serviceSegment, + MethodInfo method, + string httpMethod, + string routeSuffix) + { + var operation = new OpenApiOperation + { + OperationId = $"DynamicApi_{serviceSegment}_{httpMethod}_{method.Name}", + Summary = $"{serviceSegment} - {method.Name}", + Tags = new HashSet { new OpenApiTagReference(serviceSegment, null, serviceSegment) }, + Responses = BuildResponses(context, method) + }; + + AddParametersAndBody(context, operation, method, routeSuffix, httpMethod); + + return operation; + } + + private static OpenApiResponses BuildResponses(DocumentFilterContext context, MethodInfo method) + { + var returnDataType = UnwrapReturnDataType(method.ReturnType) ?? typeof(object); + var responseType = typeof(ApiResponse<>).MakeGenericType(returnDataType); + var responseSchema = context.SchemaGenerator.GenerateSchema(responseType, context.SchemaRepository); + + return new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "OK", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType { Schema = responseSchema } + } + } + }; + } + + private static void AddParametersAndBody( + DocumentFilterContext context, + OpenApiOperation operation, + MethodInfo method, + string routeSuffix, + string httpMethod) + { + var routeParams = ExtractRouteParameters(routeSuffix); + if (routeParams.Count > 0) + { + operation.Parameters ??= new List(); + + foreach (var routeParamName in routeParams) + { + var paramInfo = method.GetParameters() + .FirstOrDefault(p => string.Equals(p.Name, routeParamName, StringComparison.OrdinalIgnoreCase)); + + var paramType = paramInfo?.ParameterType ?? typeof(string); + var schema = context.SchemaGenerator.GenerateSchema(paramType, context.SchemaRepository); + + operation.Parameters.Add(new OpenApiParameter + { + Name = routeParamName, + In = ParameterLocation.Path, + Required = true, + Schema = schema + }); + } + } + + var requestBodyParameter = GetRequestBodyParameter(method, httpMethod); + if (requestBodyParameter == null) + { + return; + } + + var bodySchema = context.SchemaGenerator.GenerateSchema(requestBodyParameter.ParameterType, context.SchemaRepository); + operation.RequestBody = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType { Schema = bodySchema } + } + }; + } + + private static ParameterInfo? GetRequestBodyParameter(MethodInfo method, string httpMethod) + { + if (string.Equals(httpMethod, "GET", StringComparison.OrdinalIgnoreCase) || + string.Equals(httpMethod, "DELETE", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var parameters = method.GetParameters(); + if (parameters.Length == 0) + { + return null; + } + + var bodyParam = parameters.FirstOrDefault(p => p.GetCustomAttribute() != null); + if (bodyParam != null) + { + return bodyParam; + } + + return parameters.FirstOrDefault(p => !IsSimpleType(p.ParameterType)); + } + + private static Type? UnwrapReturnDataType(Type returnType) + { + if (returnType == typeof(void)) + { + return null; + } + + if (returnType == typeof(Task)) + { + return null; + } + + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + return returnType.GetGenericArguments()[0]; + } + + return returnType; + } + + private static string BuildFullPath(string serviceSegment, string routeSuffix) + { + var normalizedSuffix = string.IsNullOrWhiteSpace(routeSuffix) + ? string.Empty + : routeSuffix.StartsWith("/", StringComparison.Ordinal) ? routeSuffix : $"/{routeSuffix}"; + + return $"/api/{serviceSegment}{normalizedSuffix}"; + } + + private static List ExtractRouteParameters(string routeSuffix) + { + var list = new List(); + var suffix = routeSuffix ?? string.Empty; + + var startIndex = 0; + while (startIndex < suffix.Length) + { + var open = suffix.IndexOf('{', startIndex); + if (open < 0) + { + break; + } + + var close = suffix.IndexOf('}', open + 1); + if (close < 0) + { + break; + } + + var name = suffix.Substring(open + 1, close - open - 1).Trim(); + if (!string.IsNullOrWhiteSpace(name)) + { + list.Add(name); + } + + startIndex = close + 1; + } + + return list; + } + + private static bool TryGetRouteSuffix(Type serviceInterface, MethodInfo method, out string routeSuffix) + { + routeSuffix = string.Empty; + + var explicitRoute = method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route + ?? method.GetCustomAttribute()?.Route; + + if (!string.IsNullOrWhiteSpace(explicitRoute)) + { + routeSuffix = explicitRoute.Trim(); + return true; + } + + if (string.Equals(serviceInterface.Name, "ITaskService", StringComparison.OrdinalIgnoreCase)) + { + return TryGetTaskServiceRouteSuffix(method, out routeSuffix); + } + + return false; + } + + private static bool TryGetTaskServiceRouteSuffix(MethodInfo method, out string routeSuffix) + { + routeSuffix = string.Empty; + + return method.Name switch + { + "GetAllTasksAsync" => Return(string.Empty, out routeSuffix), + "GetTaskByIdAsync" => Return("/{id}", out routeSuffix), + "GetActiveTasksAsync" => Return("/active", out routeSuffix), + "GetCompletedTasksAsync" => Return("/completed", out routeSuffix), + "CreateTaskAsync" => Return(string.Empty, out routeSuffix), + "UpdateTaskAsync" => Return(string.Empty, out routeSuffix), + "ToggleCompleteAsync" => Return("/{id}/toggle", out routeSuffix), + "DeleteTaskAsync" => Return("/{id}", out routeSuffix), + "GetSubTasksAsync" => Return("/{parentTaskId}/subtasks", out routeSuffix), + _ => false + }; + } + + private static bool Return(string value, out string routeSuffix) + { + routeSuffix = value; + return true; + } + + private static void TrySetOperation(IOpenApiPathItem pathItem, string httpMethod, OpenApiOperation operation) + { + var operationsObject = pathItem.GetType().GetProperty("Operations")?.GetValue(pathItem); + if (operationsObject == null) + { + return; + } + + if (operationsObject is System.Collections.IDictionary dict) + { + var dictType = operationsObject.GetType(); + var keyType = dictType.IsGenericType ? dictType.GetGenericArguments()[0] : typeof(object); + dict[CreateOperationKey(keyType, httpMethod)] = operation; + return; + } + + var indexer = operationsObject.GetType().GetProperty("Item"); + var keyTypeFromIndexer = indexer?.GetIndexParameters().FirstOrDefault()?.ParameterType ?? typeof(object); + indexer?.SetValue(operationsObject, operation, new[] { CreateOperationKey(keyTypeFromIndexer, httpMethod) }); + } + + private static object CreateOperationKey(Type keyType, string httpMethod) + { + if (keyType == typeof(string)) + { + return httpMethod.ToLowerInvariant(); + } + + if (keyType.IsEnum) + { + var name = httpMethod.ToUpperInvariant() switch + { + "GET" => "Get", + "POST" => "Post", + "PUT" => "Put", + "DELETE" => "Delete", + "PATCH" => "Patch", + _ => httpMethod + }; + + return Enum.Parse(keyType, name, ignoreCase: true); + } + + return httpMethod; + } + + private static bool IsRemoteServiceEnabled(MemberInfo memberInfo) + { + var attribute = memberInfo.GetCustomAttribute(); + return attribute == null || attribute.IsEnabled; + } + + private static string GetServiceRouteSegment(Type serviceInterface) + { + var name = serviceInterface.Name; + if (name.EndsWith("Service", StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - "Service".Length); + } + + if (name.StartsWith("I", StringComparison.Ordinal) && + name.Length > 1 && + char.IsUpper(name[1])) + { + name = name.Substring(1); + } + + if (name.EndsWith("App", StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - "App".Length); + } + + return name.ToLowerInvariant(); + } + + private static string GetHttpMethod(MethodInfo method) + { + if (method.GetCustomAttribute() != null) return "GET"; + if (method.GetCustomAttribute() != null) return "POST"; + if (method.GetCustomAttribute() != null) return "PUT"; + if (method.GetCustomAttribute() != null) return "DELETE"; + if (method.GetCustomAttribute() != null) return "PATCH"; + + if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase)) return "PATCH"; + if (method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) return "GET"; + if (method.Name.StartsWith("Put", StringComparison.OrdinalIgnoreCase) || + method.Name.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) return "PUT"; + if (method.Name.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) || + method.Name.StartsWith("Remove", StringComparison.OrdinalIgnoreCase)) return "DELETE"; + if (method.Name.StartsWith("Patch", StringComparison.OrdinalIgnoreCase)) return "PATCH"; + + return "POST"; + } + + private static bool IsSimpleType(Type type) + { + return type.IsPrimitive || + type == typeof(string) || + type == typeof(decimal) || + type == typeof(DateTime) || + type == typeof(Guid) || + Nullable.GetUnderlyingType(type) != null; + } +} diff --git a/src/Hua.Todo.Application/Hua.Todo.Application.csproj b/src/Hua.Todo.Application/Hua.Todo.Application.csproj index 075eb79..793e518 100644 --- a/src/Hua.Todo.Application/Hua.Todo.Application.csproj +++ b/src/Hua.Todo.Application/Hua.Todo.Application.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs b/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs index 9fa89e6..6ac4573 100644 --- a/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs +++ b/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs @@ -44,14 +44,10 @@ public partial class MainWindow : Window { try { - if (_appSettings.WebServer.IsUsingStatic) - { - MainWebView.Url = new Uri(_webServer.BaseUrl); - } - else - { - MainWebView.Url = new Uri(_appSettings.WebServer.ForEndUrl); - } + MainWebView.Url = + _appSettings.WebServer.IsUsingStatic + ? new Uri(_appSettings.WebServer.HostUrl) + : new Uri(_appSettings.WebServer.ForEndUrl); MainWebView.NavigationCompleted += async (s, e) => { diff --git a/src/Hua.Todo.Host/Program.cs b/src/Hua.Todo.Host/Program.cs index a3a48e7..efe6e47 100644 --- a/src/Hua.Todo.Host/Program.cs +++ b/src/Hua.Todo.Host/Program.cs @@ -3,13 +3,17 @@ using Microsoft.EntityFrameworkCore; using Hua.Todo.Application; using Hua.Todo.Application.CloudSync; using Hua.Todo.Application.DynamicApi; +using Hua.Todo.Application.DynamicApi.Swagger; using Hua.Todo.Application.Interfaces; using Hua.Todo.Application.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => +{ + options.DocumentFilter(); +}); builder.Services.AddCloudSyncServer(); builder.Services.AddApplicationServices("Data Source=Hua.Todo.db"); diff --git a/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj b/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj index 9e820d1..c380fa5 100644 --- a/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj +++ b/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj @@ -1,8 +1,9 @@ - net10.0-android;net10.0-ios;net10.0-maccatalyst - $(TargetFrameworks);net10.0-windows10.0.19041.0 + net10.0-android + net10.0-windows10.0.19041.0 + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst - 1.1.9 + 1.2.0/Version> $(Version) 1 @@ -54,6 +55,10 @@ false + + icon.ico + + aab True @@ -127,7 +132,7 @@ - + @@ -138,6 +143,10 @@ + + + + @@ -200,3 +209,5 @@ + + diff --git a/src/Hua.Todo.Maui/MauiProgram.cs b/src/Hua.Todo.Maui/MauiProgram.cs index e409848..4238163 100644 --- a/src/Hua.Todo.Maui/MauiProgram.cs +++ b/src/Hua.Todo.Maui/MauiProgram.cs @@ -13,13 +13,14 @@ namespace Hua.Todo.Maui; /// /// MAUI 程序启动类 /// -public static class MauiProgram +public static partial class MauiProgram { /// /// 创建并配置 MAUI 应用程序 /// public static MauiApp CreateMauiApp() { + ConfigurePlatformWebViewContainer(); var builder = MauiApp.CreateBuilder(); builder .UseMauiApp() @@ -71,7 +72,17 @@ public static class MauiProgram // 注册嵌入式 Web 服务器(平台相关) #if WINDOWS - builder.Services.AddSingleton(); + // Windows 平台下嵌入式 WebServer 的启用策略: + // - 静态托管模式(IsUsingStatic=true):由 MAUI 内置 WebServer 提供 wwwroot 与本地 API。 + // - 开发三件套模式(IsUsingStatic=false,前端走 Vite):API 由独立 Host(5173) 提供,避免在 MAUI 内启动 WebServer 导致注入覆盖前端代理配置。 + if (appSettings.WebServer.IsUsingStatic) + { + builder.Services.AddSingleton(); + } + else + { + builder.Services.AddSingleton(); + } #elif ANDROID builder.Services.AddSingleton(); #else @@ -101,6 +112,8 @@ public static class MauiProgram return app; } + static partial void ConfigurePlatformWebViewContainer(); + /// /// 从 appsettings.json 加载配置 /// diff --git a/src/Hua.Todo.Maui/Platforms/Windows/MainPage.Windows.cs b/src/Hua.Todo.Maui/Platforms/Windows/MainPage.Windows.cs index 0242894..7646af5 100644 --- a/src/Hua.Todo.Maui/Platforms/Windows/MainPage.Windows.cs +++ b/src/Hua.Todo.Maui/Platforms/Windows/MainPage.Windows.cs @@ -1,4 +1,5 @@ using Microsoft.Maui.Controls; +using Microsoft.Maui.ApplicationModel; namespace Hua.Todo.Maui.Views { @@ -30,5 +31,53 @@ namespace Hua.Todo.Maui.Views var windowService = new Platforms.Windows.WindowsWindowService(); windowService.MinimizeWindow(window); } + + partial void PlatformPrepareWebViewContainer() + { + if (Platforms.Windows.WebView2RuntimeDetector.IsRuntimeInstalled(out _)) + { + return; + } + + _isWebViewContainerReady = false; + + var downloadUrl = "https://developer.microsoft.com/microsoft-edge/webview2/"; + var content = new VerticalStackLayout + { + Padding = new Thickness(20), + Spacing = 12, + Children = + { + new Label + { + Text = "检测到系统未安装 WebView2 Runtime,无法加载主界面。", + FontSize = 16 + }, + new Label + { + Text = "请安装 Microsoft Edge WebView2 Runtime(Evergreen),安装完成后重新打开应用。", + Opacity = 0.85 + }, + new Button + { + Text = "打开下载页面", + Command = new Command(async () => + { + await Launcher.Default.OpenAsync(downloadUrl); + }) + }, + new Button + { + Text = "退出应用", + Command = new Command(() => + { + Microsoft.Maui.Controls.Application.Current?.Quit(); + }) + } + } + }; + + Content = new ScrollView { Content = content }; + } } } diff --git a/src/Hua.Todo.Maui/Platforms/Windows/WebView2RuntimeDetector.cs b/src/Hua.Todo.Maui/Platforms/Windows/WebView2RuntimeDetector.cs new file mode 100644 index 0000000..6512fa9 --- /dev/null +++ b/src/Hua.Todo.Maui/Platforms/Windows/WebView2RuntimeDetector.cs @@ -0,0 +1,99 @@ +using System.Reflection; + +namespace Hua.Todo.Maui.Platforms.Windows; + +internal static class WebView2RuntimeDetector +{ + /// + /// 判断当前 Windows 系统是否可用 WebView2 Runtime。 + /// 优先通过已知的 Evergreen 安装目录探测(避免因托管程序集缺失/裁剪导致误判), + /// 其次再尝试通过 WebView2 SDK 的 CoreWebView2Environment.GetAvailableBrowserVersionString 获取版本。 + /// + /// 检测到的运行时版本(若可用)。 + /// 若系统存在可用的 WebView2 Runtime,则返回 true;否则返回 false + internal static bool IsRuntimeInstalled(out string? version) + { + version = null; + + try + { + version = TryGetEvergreenVersionFromKnownLocations(); + if (!string.IsNullOrWhiteSpace(version)) + { + return true; + } + + var type = Type.GetType("Microsoft.Web.WebView2.Core.CoreWebView2Environment, Microsoft.Web.WebView2.Core"); + if (type == null) + { + return false; + } + + version = TryInvokeVersionGetter(type); + return !string.IsNullOrWhiteSpace(version); + } + catch + { + return false; + } + } + + private static string? TryGetEvergreenVersionFromKnownLocations() + { + var candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "EdgeWebView", "Application"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft", "EdgeWebView", "Application"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "EdgeWebView", "Application"), + }; + + Version? best = null; + + foreach (var baseDir in candidates) + { + if (string.IsNullOrWhiteSpace(baseDir) || !Directory.Exists(baseDir)) + { + continue; + } + + foreach (var dir in Directory.EnumerateDirectories(baseDir)) + { + var name = Path.GetFileName(dir); + if (!Version.TryParse(name, out var v)) + { + continue; + } + + if (!File.Exists(Path.Combine(dir, "msedgewebview2.exe"))) + { + continue; + } + + if (best == null || v > best) + { + best = v; + } + } + } + + return best?.ToString(); + } + + private static string? TryInvokeVersionGetter(Type environmentType) + { + var noArg = environmentType.GetMethod("GetAvailableBrowserVersionString", BindingFlags.Public | BindingFlags.Static, Type.DefaultBinder, Type.EmptyTypes, null); + if (noArg != null) + { + return noArg.Invoke(null, null) as string; + } + + var oneArg = environmentType.GetMethod("GetAvailableBrowserVersionString", BindingFlags.Public | BindingFlags.Static, Type.DefaultBinder, new[] { typeof(string) }, null); + if (oneArg != null) + { + return oneArg.Invoke(null, new object?[] { null }) as string; + } + + return null; + } +} + diff --git a/src/Hua.Todo.Maui/README.md b/src/Hua.Todo.Maui/README.md index 1c91a6e..90e2e6f 100644 --- a/src/Hua.Todo.Maui/README.md +++ b/src/Hua.Todo.Maui/README.md @@ -62,6 +62,25 @@ cd Hua.Todo.Maui dotnet build -f net10.0-windows10.0.19041.0 dotnet run -f net10.0-windows10.0.19041.0 ``` +Windows Debug 编译下,MAUI 内嵌 WebServer 会提供接口文档: +- Swagger UI:`{HostUrl}/swagger` +- OpenAPI JSON:`{HostUrl}/swagger/v1/swagger.json` +其中 `{HostUrl}` 来自 `appsettings.json: WebServer.HostUrl`(默认 `http://localhost:5057`)。 +按默认配置,对应地址为: +- Swagger UI:`http://localhost:5057/swagger` +- OpenAPI JSON:`http://localhost:5057/swagger/v1/swagger.json` + +#### Windows(三件套热更新:MAUI + Vite + Host) +该模式用于开发阶段获得最佳热更新体验: +- `Hua.Todo.Host`:提供 API(`http://localhost:5173`) +- `Hua.Todo.Web`:Vite dev server(`http://localhost:5174`),并将 `/api` 代理到 5173 +- `Hua.Todo.Maui`:WebView 加载 5174 +在该模式下,Swagger UI 地址为:`http://localhost:5173/swagger`(仅 `ASPNETCORE_ENVIRONMENT=Development` 时启用)。 + +```powershell +.\start-dev.ps1 +``` +然后在 Visual Studio 中启动 `Hua.Todo.Maui`(F5)。 #### macOS ```bash @@ -134,7 +153,9 @@ dotnet run -f net10.0-android 1. **macOS 权限**: 首次运行时需要在系统设置中授予辅助功能权限 2. **Windows UAC**: 某些情况下可能需要管理员权限 3. **移动端限制**: 移动端不支持真正的全局快捷键,使用通知快捷方式替代 -4. **WebView**: 确保 Hua.Todo.Api 服务在 `http://localhost:5173` 运行 +4. **WebView(开发)**: 三件套模式下需要确保 `Hua.Todo.Host` 在 `http://localhost:5173` 运行,同时 `Hua.Todo.Web` 在 `http://localhost:5174` 运行 +5. **Windows WebView2 数据目录**: WebView2 的缓存/存储写入 `%LocalAppData%\Hua.Todo\WebView2`,避免在安装目录生成 `*.WebView2` 文件夹 +6. **Windows WebView2 Runtime**: 依赖系统安装的 Microsoft Edge WebView2 Runtime;若缺失应用会提示下载安装 ## 后续计划 @@ -146,4 +167,4 @@ dotnet run -f net10.0-android ## 许可证 -AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN)) \ No newline at end of file +AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN)) diff --git a/src/Hua.Todo.Maui/Services/AppMetadata.cs b/src/Hua.Todo.Maui/Services/AppMetadata.cs index 801a1ea..8c9d964 100644 --- a/src/Hua.Todo.Maui/Services/AppMetadata.cs +++ b/src/Hua.Todo.Maui/Services/AppMetadata.cs @@ -6,7 +6,7 @@ namespace Hua.Todo.Maui.Services; public static class AppMetadata { - private const string AppNameText = "\u5F85\u529E\u4E8B\u9879"; + private const string AppNameText = "Hua.Todo"; public static string AppName => AppNameText; diff --git a/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs b/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs index c192dba..2410bcc 100644 --- a/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs +++ b/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using System.Text.Json; using Hua.Todo.Application; using Hua.Todo.Application.DynamicApi; +using Hua.Todo.Application.DynamicApi.Swagger; using Hua.Todo.Maui.Models; using AppSettings = Hua.Todo.Maui.Models.AppSettings; @@ -65,6 +66,12 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService }); builder.Services.AddEndpointsApiExplorer(); +#if DEBUG + builder.Services.AddSwaggerGen(options => + { + options.DocumentFilter(); + }); +#endif // 注册应用逻辑服务 builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); @@ -82,6 +89,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService var app = builder.Build(); +#if DEBUG + app.UseSwagger(); + app.UseSwaggerUI(); +#endif + // 如果配置为使用静态文件(前端托管),则配置静态文件服务 if (_appSettings.WebServer.IsUsingStatic) { @@ -135,7 +147,18 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService if (context.Request.Path.HasValue) { var path = context.Request.Path.Value; - if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase)) + + // Swagger 仅在 DEBUG 下启用;Release 下不应把 /swagger 当作“后端专用路径”排除, + // 否则访问 /swagger 会直接 404 而不会回落到 SPA(/index.html)。 +#if DEBUG + var isSwaggerPath = path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase); +#else + var isSwaggerPath = false; +#endif + if (path != "/" + && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) + && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase) + && !isSwaggerPath) { var ext = Path.GetExtension(path); if (string.IsNullOrEmpty(ext)) diff --git a/src/Hua.Todo.Maui/Views/MainPage.xaml.cs b/src/Hua.Todo.Maui/Views/MainPage.xaml.cs index c9e0d8d..484912c 100644 --- a/src/Hua.Todo.Maui/Views/MainPage.xaml.cs +++ b/src/Hua.Todo.Maui/Views/MainPage.xaml.cs @@ -12,6 +12,7 @@ namespace Hua.Todo.Maui.Views { private readonly AppSettings _appSettings; private readonly IEmbeddedWebServerService? _webServer; + private bool _isWebViewContainerReady = true; /// /// 创建 。 @@ -24,6 +25,12 @@ namespace Hua.Todo.Maui.Views _appSettings = appSettings; _webServer = webServer; + PlatformPrepareWebViewContainer(); + if (!_isWebViewContainerReady) + { + return; + } + SetupWebViewSource(); SetupWebViewCommunication(); SetupKeyboardHandler(); @@ -141,10 +148,12 @@ namespace Hua.Todo.Maui.Views /// 平台特定的键盘处理器初始化。 /// partial void PlatformSetupKeyboardHandler(); + /// /// 平台特定的 Esc 键处理逻辑。 /// /// 当前窗口。 partial void PlatformOnEscKeyPressed(Window window); + partial void PlatformPrepareWebViewContainer(); } } diff --git a/src/Hua.Todo.Maui/appsettings.json b/src/Hua.Todo.Maui/appsettings.json index 842ac5f..6310f91 100644 --- a/src/Hua.Todo.Maui/appsettings.json +++ b/src/Hua.Todo.Maui/appsettings.json @@ -1,10 +1,10 @@ { "WebServer": { "Port": 5057, - "IsUsingStatic": false, + "IsUsingStatic": true, "ConnectionString": "", "HostUrl": "http://localhost:5057", - "ForEndUrl": "http://localhost:5174" + "ForEndUrl": "http://localhost:5057" }, "Development": { }, diff --git a/src/Hua.Todo.Maui/setup.iss b/src/Hua.Todo.Maui/setup.iss index 9d58c2c..d4119fe 100644 --- a/src/Hua.Todo.Maui/setup.iss +++ b/src/Hua.Todo.Maui/setup.iss @@ -1,5 +1,5 @@ #define MyAppName "Hua.Todo" -#define MyAppVersion "1.1.8" +#define MyAppVersion "1.1.10" #define MyAppPublisher "ShaoHua" #define MyAppURL "https://git.we965.cn/Tools/Hua.Todo" #define MyAppExeName "Hua.Todo.Maui.exe" @@ -34,6 +34,7 @@ PrivilegesRequired=lowest OutputDir=Output OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion} SetupIconFile=icon.ico +UninstallDisplayIcon={app}\icon.ico SolidCompression=no WizardStyle=modern @@ -47,9 +48,15 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ Source: "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs ; 注意: 请勿在任何共享系统文件上使用“Flags: ignoreversion” +[InstallDelete] +Type: filesandordirs; Name: "{app}\{#MyAppExeName}.WebView2" + +[UninstallDelete] +Type: filesandordirs; Name: "{app}\{#MyAppExeName}.WebView2" + [Icons] -Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\icon.ico" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico" [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/src/Hua.Todo.Web/src/api/client.ts b/src/Hua.Todo.Web/src/api/client.ts index 8176014..f1811fb 100644 --- a/src/Hua.Todo.Web/src/api/client.ts +++ b/src/Hua.Todo.Web/src/api/client.ts @@ -48,6 +48,13 @@ apiClient.interceptors.response.use( console.error('API Error:', error); let errorMessage = '网络错误,请稍后再试'; + const status = error?.response?.status as number | undefined; + if (status === 502 || status === 503 || status === 504) { + errorMessage = '后端服务不可达:请确认 Hua.Todo.Host 已启动(http://localhost:5173)'; + showNotification(errorMessage, 'error'); + return Promise.reject(error); + } + if (error.response && error.response.data) { const data = error.response.data; const errors = data.errors || data.Errors; diff --git a/src/Hua.Todo.Web/src/api/cloudClient.ts b/src/Hua.Todo.Web/src/api/cloudClient.ts index 684e854..67c3431 100644 --- a/src/Hua.Todo.Web/src/api/cloudClient.ts +++ b/src/Hua.Todo.Web/src/api/cloudClient.ts @@ -9,6 +9,14 @@ const showNotification = (message: string, type: 'error' | 'success' = 'error') } }; +let lastOpenCloudSyncSettingsAt = 0; +const requestCloudSyncReLogin = (): void => { + const now = Date.now(); + if (now - lastOpenCloudSyncSettingsAt < 1500) return; + lastOpenCloudSyncSettingsAt = now; + window.dispatchEvent(new CustomEvent('openCloudSyncSettings')); +}; + /** * 云同步专用 HTTP Client。 * @@ -43,6 +51,15 @@ cloudClient.interceptors.response.use( (error) => { let errorMessage = '网络错误,请稍后再试'; + const status = error?.response?.status as number | undefined; + if (status === 401 || status === 403) { + CloudSyncStorage.clearSession(); + requestCloudSyncReLogin(); + errorMessage = status === 401 ? '云同步会话已过期,请重新登录' : '云同步权限不足,请重新登录或联系管理员'; + showNotification(errorMessage, 'error'); + return Promise.reject(error); + } + if (error?.response?.data) { const data = error.response.data; if (typeof data === 'string' && data.trim()) { diff --git a/start-dev.ps1 b/start-dev.ps1 new file mode 100644 index 0000000..cfc707a --- /dev/null +++ b/start-dev.ps1 @@ -0,0 +1,49 @@ +param( + [switch]$Force = $false, + [switch]$StartMaui = $false +) + +$ErrorActionPreference = "Stop" + +Write-Host "====================================" -ForegroundColor Cyan +Write-Host " Hua.Todo 三件套启动" -ForegroundColor Cyan +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" + +$pwsh = (Get-Command "pwsh" -ErrorAction SilentlyContinue)?.Source +if (-not $pwsh) { + $pwsh = (Get-Command "powershell" -ErrorAction SilentlyContinue)?.Source +} + +if (-not $pwsh) { + Write-Host "❌ 未找到 PowerShell 可执行文件(pwsh/powershell)" -ForegroundColor Red + exit 1 +} + +$forceArgs = @() +if ($Force) { $forceArgs += "-Force" } + +Write-Host "[1/3] 启动后端 Host (5173)..." -ForegroundColor Yellow +Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-host.ps1")) + $forceArgs | Out-Null + +Write-Host "[2/3] 启动前端 Vite (5174)..." -ForegroundColor Yellow +Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-forend.ps1")) + $forceArgs | Out-Null + +Write-Host "[3/3] 启动 MAUI..." -ForegroundColor Yellow +if ($StartMaui) { + Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-maui.ps1"), "-SkipWebBuild") + $forceArgs | Out-Null + Write-Host "✓ 已通过脚本启动 MAUI(跳过内置 Web 构建)" -ForegroundColor Green +} else { + Write-Host "✓ 已启动 Host + Vite。请在 Visual Studio 中启动 Hua.Todo.Maui(F5)进行调试。" -ForegroundColor Green +} + +Write-Host "" +Write-Host "💡 约定端口:" -ForegroundColor Yellow +Write-Host " - Host API: http://localhost:5173" -ForegroundColor Cyan +Write-Host " - Vite Dev: http://localhost:5174" -ForegroundColor Cyan +Write-Host "" +Write-Host "📝 提示:" -ForegroundColor Yellow +Write-Host " - 使用 -Force 可强制重启已运行的进程" -ForegroundColor Gray +Write-Host " - 使用 -StartMaui 也可用脚本拉起 MAUI(不等同于 VS 调试启动)" -ForegroundColor Gray +Write-Host "" + diff --git a/start-host.ps1 b/start-host.ps1 new file mode 100644 index 0000000..c1cc164 --- /dev/null +++ b/start-host.ps1 @@ -0,0 +1,90 @@ +param( + [switch]$Force = $false +) + +$ErrorActionPreference = "Stop" + +Write-Host "====================================" -ForegroundColor Cyan +Write-Host " Hua.Todo.Host 启动脚本" -ForegroundColor Cyan +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" + +$hostProjectPath = Join-Path $PSScriptRoot "src\Hua.Todo.Host" +$hostProjectFile = Join-Path $hostProjectPath "Hua.Todo.Host.csproj" + +if (!(Test-Path $hostProjectFile)) { + Write-Host "❌ Host 项目文件不存在: $hostProjectFile" -ForegroundColor Red + exit 1 +} + +if (!(Get-Command "dotnet" -ErrorAction SilentlyContinue)) { + Write-Host "❌ 未找到 dotnet,请先安装 .NET SDK" -ForegroundColor Red + exit 1 +} + +Write-Host "[1/2] 检查 Hua.Todo.Host 是否已在运行..." -ForegroundColor Yellow + +$runningProcesses = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe'" | Where-Object { + $_.CommandLine -and ( + $_.CommandLine -like "*Hua.Todo.Host*" -or + $_.CommandLine -like "*Hua.Todo.Host.dll*" + ) +} + +if ($runningProcesses) { + $pids = ($runningProcesses.ProcessId | Sort-Object) -join ", " + Write-Host "⚠️ Hua.Todo.Host 可能已在运行中" -ForegroundColor Yellow + Write-Host " 进程 ID: ($pids)" -ForegroundColor Gray + + if ($Force) { + Write-Host "" + Write-Host "🔄 强制关闭并重新启动..." -ForegroundColor Yellow + try { + $runningProcesses | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } + Write-Host "✅ 旧进程已停止" -ForegroundColor Green + Start-Sleep -Seconds 1 + } catch { + Write-Host "❌ 停止进程失败: $_" -ForegroundColor Red + exit 1 + } + } else { + Write-Host " 如果需要重新启动,请使用 -Force 参数" -ForegroundColor Gray + Write-Host "" + Write-Host "💡 API 地址:" -ForegroundColor Yellow + Write-Host " http://localhost:5173" -ForegroundColor Cyan + Write-Host "" + exit 0 + } +} else { + Write-Host "✓ Hua.Todo.Host 未运行" -ForegroundColor Green + Write-Host "" +} + +Write-Host "[2/2] 启动 Hua.Todo.Host..." -ForegroundColor Yellow +Write-Host "📂 工作目录: $hostProjectPath" -ForegroundColor Gray +Write-Host "🚀 启动服务..." -ForegroundColor Green +Write-Host "" + +try { + $argumentList = @( + "run", + "--project", $hostProjectFile, + "--launch-profile", "http" + ) + + $hostProcess = Start-Process -FilePath "dotnet" -ArgumentList $argumentList -WorkingDirectory $hostProjectPath -PassThru + + Write-Host "✅ Hua.Todo.Host 已启动" -ForegroundColor Green + Write-Host " 进程 ID: ($($hostProcess.Id))" -ForegroundColor Gray + Write-Host "" + Write-Host "💡 API 地址:" -ForegroundColor Yellow + Write-Host " http://localhost:5173" -ForegroundColor Cyan + Write-Host "" + Write-Host "📝 提示:" -ForegroundColor Yellow + Write-Host " - 使用 -Force 参数可以强制重启" -ForegroundColor Gray + Write-Host "" +} catch { + Write-Host "❌ 启动失败: $_" -ForegroundColor Red + exit 1 +} +