Compare commits

..

6 Commits

Author SHA1 Message Date
ShaoHua 3dbd97103c feat:1.2.0初始版本未自测 2026-04-13 23:10:07 +08:00
ShaoHua 1f87565d5a 1.MAUI Android可以正常显示 2026-04-13 21:17:15 +08:00
ShaoHua d94d1f36d2 1.maui支持Android版本 2026-04-13 00:06:53 +08:00
ShaoHua d53828c150 feat: v1.2.0 开发进度更新
### 新增功能
- **Linux 官方支持**:新增 Hua.Todo.Avalonia 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化
- **SQLite DateTime 兼容修复**:新增 LenientUtcDateTimeStringConverter,解决历史遗留的 DateTime 脏数据解析问题
- **用户文档完善**:新增 docs/manual/新手指南.md 和 docs/manual/用户指南.md
- **部署文档**:新增 docs/manual/部署文档.md,详细说明多平台发布流程

### 优化与修复
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 publish.ps1 作为统一入口
- **Windows WebView2 优化**:数据目录调整到 %LocalAppData%\Hua.Todo\WebView2,修复 Runtime 误判问题
- **MAUI 多平台构建**:在 Windows 开发机上默认仅构建 Android + Windows 目标
- **SPA 路由回落**:修复 Release 模式下 /swagger 路径的 404 问题
- **Swagger 输出**:补齐 Dynamic API 端点,避免接口缺失

### 文档更新
- **版本记录**:更新 v1.2.0 开发进度和功能列表
- **技术设计文档**:添加 Avalonia 项目架构和模块设计
- **项目结构**:更新 README.md 中的项目结构说明

### 其他变更
- 新增 Directory.Build.props 和更新 Directory.Build.targets
- 调整 src/Hua.Todo.Avalonia 项目配置和资源文件
- 更新 src/Hua.Todo.Web 前端资源文件
- 修复 src/Hua.Todo.Maui 相关配置和打包脚本
2026-04-09 21:39:07 +08:00
ShaoHua 8443d14ba6 发布版本 2026-04-08 20:16:04 +08:00
ShaoHua 04263dff4e refactor: 重构待办事项模块结构与命名 2026-04-08 19:59:50 +08:00
109 changed files with 5101 additions and 631 deletions
+6
View File
@@ -368,3 +368,9 @@ 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
/.artifacts
/.android-sdk
/src/Hua.Todo.Avalonia/wwwroot
/src/Hua.Todo.Avalonia/wwwroot
-18
View File
@@ -1,18 +0,0 @@
# 协调目录(并行 solo 专用)
该目录用于解决两类问题:
- 文件冲突:多窗口并行时明确“谁是 Writer”,其他人不直接改同一文件
- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进
## 目录约定
- `ownership.md`:文件/目录所有权登记(Writer 表)
- `shared-files.md`:本阶段共享文件清单(由 Integrator 维护)
- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘)
- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件)
## 使用规则
- 所有权与共享文件清单优先使用“仓库相对路径”
- 禁止记录或提交构建产物目录中的文件路径(如 `bin/``obj/``node_modules/``dist/` 等)
-13
View File
@@ -1,13 +0,0 @@
# 文件/目录所有权(Writer)登记
规则:
- 同一时段内,同一个文件只能有一个 Writer
- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘
- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码
## 当前所有权
| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 |
|---|---|---|---|
| `src\<module>\<file>` | `<name>` | `<task-id>` | `共享:是/否` |
-11
View File
@@ -1,11 +0,0 @@
# 本阶段共享文件清单(Integrator 维护)
规则:
- 本文件只由 Integrator 修改,避免反复冲突
- 清单内每条必须是“仓库相对路径”,并说明为什么共享(入口/协议/配置/依赖锁等)
- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator
## 共享文件
- `src\<module>\<file>``<why-shared>`
-33
View File
@@ -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 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
-34
View File
@@ -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. [ ] 文档变更是否保持“局部修改”,只影响与本任务相关的段落/小节?(避免无关重排/改写)
@@ -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\<module>\<file>`
- **绝对路径(可选)**`<repo-root>\src\<module>\<file>``<repo-root>` 为本机仓库根目录)
- Touch List 禁止包含构建产物与临时目录中的文件(这些文件不应被手工修改,且极易产生冲突),包括但不限于:
- `**\bin\**``**\obj\**`
- `**\node_modules\**`
- `**\.vite\**``**\dist\**`
### Touch List 模板(复制即可用)
- Touch List:
- `src\<module>\<file>`(共享:否|Writer:本窗口)
- `src\<module>\<file>`(共享:是|Writer<窗口名>
- `docs\<file>`(共享:是/否|Writer<窗口名>
- `.trae\<file>`(共享:是|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. [ ] 若涉及接口演进,是否采用兼容期策略而非一次性破坏式变更?
-50
View File
@@ -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` 标注“已完成”,并在“待验证表”里更新状态?
+2
View File
@@ -20,6 +20,8 @@
"args": [
"publish",
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
"-c",
"Release",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
+25
View File
@@ -0,0 +1,25 @@
<Project>
<PropertyGroup>
<!-- 统一版本号 -->
<Version>1.2.8</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- 通用元数据 -->
<Authors>ShaoHua</Authors>
<Company>Hua.Todo</Company>
<Product>Hua.Todo</Product>
<Copyright>Copyright © 2024 ShaoHua</Copyright>
<Description>A simple cross-platform Todo application.</Description>
<!-- 编译优化选项 -->
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现“文件正由另一进程使用” (XARDF7024) 的问题。 -->
<MSBuildDisableNodeReuse>true</MSBuildDisableNodeReuse>
</PropertyGroup>
</Project>
+3 -3
View File
@@ -1,8 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True)">true</UseMonoRuntime>
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RIDwin-*/linux-*/osx-*)时强制禁用,避免还原阶段解析对应的 Mono runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')) != True))">true</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
</PropertyGroup>
</Project>
+1 -1
View File
@@ -11,7 +11,7 @@
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
<Build Solution="Debug|*" Project="false" />
<Deploy Solution="Debug|Any CPU" />
</Project>
</Folder>
</Solution>
+28 -17
View File
@@ -1,17 +1,16 @@
# Hua.Todo 跨平台代办管理应用
# Hua.Todo 跨平台代办管理应用 v1.2.8
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**后端通过 RESTful API 进行数据交互
- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示)
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux
- **任务管理**:支持创建、编辑、删除、完成状态切换、子任务管理。
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略(SecurityPolicy)配置。
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感。
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析。
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试。
## 📦 安装与使用
@@ -38,6 +37,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,10 +47,18 @@ 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``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`
- 运行 `publish-windows.ps1` 默认使用 `Release` 配置生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`,且均默认使用 `Release` 配置。
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件)
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json``WebServer.IsUsingStatic=true`
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件)
@@ -88,12 +96,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` - 获取子任务列表
## 🤝 交流与贡献
+26 -18
View File
@@ -1,4 +1,4 @@
# Hua.Todo 代码规范文档 v1.1.0
# Hua.Todo 代码规范文档 v1.2.8
## 1. 概述
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
@@ -10,21 +10,29 @@
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
- **一致性**: 在整个项目中保持命名风格一致
### 2.2 注释规范
- **公共 API 必须添加 XML 文档注释**
### 2.2 注释规范 (强制)
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
- ** summary**:一句话说明用途
- ** param / returns**:关键参数/返回值说明
- ** 异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
- **复杂逻辑添加行内注释**
- **避免注释显而易见的代码**
- **保持注释与代码同步更新**
- **禁止在日志或注释中输出密钥、Token、用户隐私信息**
### 2.3 代码格式化
- **使用统一的代码格式化工具**
- **使用统一的代码格式化工具** (VS / IDE 默认格式化)
- **保持一致的缩进和空格**
- **每行代码不超过 120 字符**
- **文件末尾保留一个空行**
## 3. C# 代码规范
### 3.1 命名规范
### 3.1 跨平台逻辑规范
- **禁止混写 `#if`**:禁止在同一文件内混写多个平台的大段 `#if` 实现。
- **优先使用 partial/接口**:应优先使用 `partial` 类、接口与平台目录分离。
- **说明平台差异**:平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
### 3.2 异步/后台任务
- **必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入**。
#### 类和接口
```csharp
@@ -314,7 +322,7 @@ const task = response.data as Task; // 避免
```typescript
// 使用 async/await 而非 Promise 链
async function getTasks(): Promise<Task[]> {
const response = await axios.get('/api/tasks');
const response = await axios.get('/api/task');
return response.data;
}
@@ -457,7 +465,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 +489,30 @@ export const useTaskStore = defineStore('tasks', () => {
### 6.1 RESTful API 设计
```csharp
// 使用名词复数形式
[HttpGet("tasks")]
// 本项目的任务端点采用 Dynamic API 路由约定:/api/task(由中间件反射分发)
[HttpGet("task")]
public async Task<ActionResult<List<Task>>> GetTasks()
{
}
// 使用资源 ID
[HttpGet("tasks/{id}")]
[HttpGet("task/{id}")]
public async Task<ActionResult<Task>> GetTask(int id)
{
}
// 使用 HTTP 方法表示操作
[HttpPost("tasks")]
[HttpPost("task")]
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
{
}
[HttpPut("tasks/{id}")]
public async Task<ActionResult<Task>> UpdateTask(int id, UpdateTaskDto dto)
[HttpPut("task")]
public async Task<ActionResult<Task>> UpdateTask(UpdateTaskDto dto)
{
}
[HttpDelete("tasks/{id}")]
[HttpDelete("task/{id}")]
public async Task<ActionResult> DeleteTask(int id)
{
}
@@ -563,7 +571,7 @@ return BadRequest(new ApiResponse<object>
```
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 +617,7 @@ public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
public async Task GetTasks_ReturnsSuccessAndCorrectContentType()
{
// Act
var response = await _client.GetAsync("/api/tasks");
var response = await _client.GetAsync("/api/task");
// Assert
response.EnsureSuccessStatusCode();
+13 -9
View File
@@ -3,19 +3,19 @@
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **开发语言**C# 13 (基于 .NET 10)
- **框架**.NET 10 (Preview/Early Adopter)
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
- **API 框架**ASP.NET Core Web API (支持动态 API 生成)
- **数据访问**Entity Framework Core 10.0
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript
- **开发语言**TypeScript 5+
- **框架**Vue.js 3
- **构建工具**Vite
- **构建工具**Vite 5+
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
@@ -24,13 +24,17 @@
## 🎯 核心模块说明
### Hua.Todo.Core
领域实体层,定义核心实体(TaskEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
领域实体层,定义核心实体(TaskEntity)、安全策略实体(SecurityPolicyEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
### Hua.Todo.Application
应用层实现,包含业务逻辑、动态 API 生成逻辑、EF Core 数据库上下文以及具体的服务实现(TaskService)。
应用层实现,包含
- **业务逻辑**TaskService 实现。
- **动态 API**:基于 Middleware 的动态 API 生成逻辑。
- **云同步 (CloudSync)**:包含 Auth 验证、同步服务、安全策略管理等。
- **数据访问**EF Core 数据库上下文(TodoDbContext)与迁移。
### Hua.Todo.Host
后端 API 宿主,提供运行环境和配置,是后端服务的启动入口
后端 API 宿主,作为独立 Server 运行时提供运行环境和配置。
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
+85 -27
View File
@@ -8,7 +8,7 @@
### 2.1 后端技术栈
- **开发语言**: C# 10
- **框架**: .NET 10
- **UI 框架**: MAUI (Multi-platform App UI)
- **UI 框架**: MAUI (Multi-platform App UI) + Avalonia (Linux 支持)
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
- **API 框架**: ASP.NET Core Web API
- **数据访问**: Entity Framework Core
@@ -34,7 +34,8 @@ Hua.Todo/
│ │ ├── 技术栈与模块.md
│ │ ├── 版本记录.md
│ │ ├── 技术设计文档.md(本文件)
│ │ ── 代码规范文档.md
│ │ ── 代码规范文档.md
│ │ └── 部署文档.md
│ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md
│ └── ...
@@ -73,7 +74,30 @@ Hua.Todo/
│ │ ├── MauiProgram.cs # MAUI 程序配置
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
│ │
│ ├── Hua.Todo.Api/ # 后端 API 项目
│ ├── Hua.Todo.Avalonia/ # Avalonia 项目(Linux 支持)
│ │ ├── Assets/ # 资源文件
│ │ │ └── icon.ico # 应用图标
│ │ ├── Services/ # 服务层
│ │ │ ├── EmbeddedWebServerServiceFactory.cs
│ │ │ ├── GlobalHotKeyServiceFactory.cs
│ │ │ ├── NoopEmbeddedWebServerService.cs
│ │ │ └── Platforms/ # 平台特定服务
│ │ │ ├── AndroidGlobalHotKeyService.cs
│ │ │ └── LinuxGlobalHotKeyService.cs
│ │ ├── Views/ # 视图
│ │ │ ├── MainView.axaml
│ │ │ ├── MainView.axaml.cs
│ │ │ ├── MainWindow.axaml
│ │ │ └── MainWindow.axaml.cs
│ │ ├── App.axaml # Avalonia 应用入口
│ │ ├── App.axaml.cs
│ │ ├── Program.cs # Avalonia 程序配置
│ │ ├── appsettings.json # 配置文件
│ │ ├── setup.iss # 安装脚本
│ │ ├── wwwroot/ # 前端静态资源
│ │ └── Hua.Todo.Avalonia.csproj # Avalonia 项目文件
│ │
│ ├── Hua.Todo.Host/ # 后端 API 项目
│ │ ├── Controllers/ # API 控制器
│ │ │ ├── TasksController.cs
│ │ │ ├── SettingsController.cs
@@ -89,6 +113,8 @@ Hua.Todo/
│ │ │ └── SyncService.cs
│ │ ├── Data/ # 数据访问层
│ │ │ ├── TodoDbContext.cs
│ │ │ ├── Converters/ # 类型转换器
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
@@ -99,7 +125,14 @@ Hua.Todo/
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ │ └── Hua.Todo.Host.csproj # Host 项目文件
│ │
│ ├── Hua.Todo.Application/ # 应用层
│ │ ├── Data/ # 数据访问
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Converters/ # 类型转换器
│ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ └── Hua.Todo.Application.csproj # Application 项目文件
│ │
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/ # 实体类
@@ -116,7 +149,7 @@ Hua.Todo/
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ │ └── index.html
│ │ │ └── favicon.svg # 网站图标
│ │ ├── src/ # 源代码
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts # HTTP 客户端配置
@@ -173,26 +206,48 @@ 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 Avalonia 项目 (Hua.Todo.Avalonia)
**职责**:
- Linux 平台支持
- 桌面交互功能(托盘菜单、全局热键等)
- WebView 容器管理
- 本地 HTTP 服务器启动
**关键组件**:
- `Program.cs`: 配置 Avalonia 应用和依赖注入
- `App.axaml.cs`: 应用程序主入口
- `MainWindow.axaml.cs`: 主窗口管理
- `MainView.axaml.cs`: 主视图管理
- `GlobalHotKeyServiceFactory`: 全局热键服务工厂
- `EmbeddedWebServerServiceFactory`: 内嵌 Web 服务器服务工厂
- 平台特定服务: Linux 全局热键等
### 4.3 后端 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 服务器配置和启动
### 4.3 核心业务层 (Hua.Todo.Core)
### 4.4 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
@@ -204,7 +259,7 @@ Hua.Todo/
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.4 前端 Web 项目 (Hua.Todo.Web)
### 4.5 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
@@ -220,33 +275,36 @@ Hua.Todo/
## 5. HTTP API 设计
### 5.1 API 基础配置
- **基础 URL**: `http://localhost:5000/api`
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`
- **基础 URLMAUI 内嵌模式)**: `{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
#### 云同步 APIHost 模式)
```
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. 数据库设计
+26 -5
View File
@@ -9,15 +9,36 @@
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### v1.2.0(开发中,2026-04-07
### v1.2.8 (2026-04-13)
- **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。
- **动态 API 增强**:完善 `DynamicApi` 逻辑,支持 Swagger 自动过滤与中间件拦截。
- **代码规范同步**:强制执行 XML 文档注释与跨平台逻辑分离规范,更新 `.trae/rules` 规则库。
- **多平台构建优化**:优化 `Directory.Build.props``Directory.Build.targets`,精细化控制各平台(Windows/Android/iOS/Linux)的构建开关与依赖。
- **版本号统一**:全项目版本号提升至 `v1.2.8`,同步更新各平台安装包与发布脚本。
### v1.2.02026-04-07
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示)。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口
- **MAUIWindows)内嵌 API 文档**Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
- **Android 启动稳定性修复**:在 AndroidManifest 中移除 `androidx.startup.InitializationProvider` 自动初始化入口,规避 `androidx.lifecycle.ProcessLifecycleInitializer` 缺失导致的启动崩溃(`NoClassDefFoundError`)。
- **MAUI Android 调试配置修复**:在 `Hua.Todo.Maui.csproj` 中显式启用 `AndroidApplication`,并将调试架构配置从 `AndroidSupportedAbis` 切换为 `RuntimeIdentifiers=android-x64`,减少 Visual Studio 启动 Android 调试时的项目识别与模拟器架构问题。
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。
- **SQLite DateTime 兼容修复**:新增 `LenientUtcDateTimeStringConverter`,本地数据库中若存在历史遗留的 DateTime “ticks/时间戳字符串”脏数据,读取时将被兼容解析,避免 `/api/task` 等查询因单条坏数据整体失败。
- **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。
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值
### v1.1.1 (2026-04-06)
- **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `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` 代理配置。
- **文档与部署指南**:新增 `docs/manual/部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。
- **用户文档完善**:在规划中新增了 `docs/manual/新手指南.md``docs/manual/用户指南.md`
27→
28→### v1.1.1 (2026-04-06)
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
- **项目结构说明校准**:修正 README.md 和技术文档中对 `Hua.Todo.Host``Hua.Todo.Application` 等模块的路径与职责描述。
+128
View File
@@ -0,0 +1,128 @@
# Hua.Todo 构建与部署手册
本文档提供 Hua.Todo 项目的版本构建指南与多平台部署流程,侧重于如何产出可分发的版本并将其安装或部署到目标机器。
## 1. 🏗️ 版本构建流程 (Release Build)
在进行任何部署之前,必须先通过自动化脚本构建出 Release 版本的产物。
### 1.1 前提要求
- **环境检查**:已安装 .NET 10 SDK、Node.js 18+ 以及 Inno Setup 6(仅 Windows 打包需要)。
- **脚本入口**:位于根目录的 `publish.ps1` 系列脚本。
### 1.2 执行构建
根据目标平台执行对应的构建命令:
- **全平台一键构建**
```powershell
.\publish.ps1 -Windows -Linux
```
- **Windows 版本构建**
```powershell
.\publish-windows.ps1
```
- **Linux 版本构建**
```powershell
.\publish-linux.ps1 -RuntimeIdentifier linux-x64 -SelfContained
```
### 1.3 产物输出位置
所有构建产物将按平台归类在根目录的 `artifacts/` 文件夹下:
- **Windows**: `artifacts\windows\win-x64\installer\` (安装包) 与 `publish\` (解压即用版)
- **Linux**: `artifacts\linux\linux-x64\` (`.tar.gz` 压缩包)
***
## 2. 🪟 Windows 客户端部署
### 2.1 安装包分发 (Recommended)
1. **分发文件**:将 `hua.todo-{version}-win-x64-setup.exe` 提供给终端用户。
2. **安装流程**:用户运行安装程序,程序将自动:
- 安装到 `%ProgramFiles%\Hua.Todo`(或用户自定义路径)。
- 创建桌面与开始菜单快捷方式。
- 写入注册表以支持卸载。
3. **静默安装**:支持 Inno Setup 标准静默参数 `/VERYSILENT /SUPPRESSMSGBOXES`
### 2.2 绿色版部署
1. **分发文件**:将 `artifacts\windows\win-x64\publish\` 文件夹整体打包。
2. **运行要求**:目标机器需安装 **WebView2 Runtime**
3. **启动程序**:运行 `Hua.Todo.Maui.exe`
***
## 3. 🐧 Linux 客户端部署
目前提供基于 Avalonia 的 Linux 交付版本,采用 `.tar.gz` 压缩包形式。
### 3.1 解压部署
1. **解压**
```bash
tar -xzf hua.todo-{version}-linux-x64.tar.gz -C /opt/hua-todo
```
2. **运行环境**
- 确保系统安装了 `libwebkit2gtk-4.0-37`。
- 如果构建时未开启 `-SelfContained`,则需安装 .NET 10 Runtime。
3. **权限设置**
```bash
chmod +x /opt/hua-todo/Hua.Todo.Avalonia
```
4. **启动**:直接运行 `/opt/hua-todo/Hua.Todo.Avalonia`。
***
## 4. ☁️ 云同步服务端部署 (Server)
`Hua.Todo.Host` 可作为中心服务端部署,用于任务的云端同步。
### 4.1 Docker 部署 (Recommended)
1. **上传代码**或将 `src/Hua.Todo.Host/Dockerfile` 复制到服务器。
2. **构建与启动**
```bash
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
docker run -d \
--name hua-todo-server \
-p 5173:5173 \
-v /data/hua-todo/db:/app/data \
-e ASPNETCORE_ENVIRONMENT=Production \
hua-todo-server
```
### 4.2 直接部署 (Binary)
1. **构建 Host**`dotnet publish src/Hua.Todo.Host -c Release -o ./dist`。
2. **同步产物**:将 `./dist` 内容同步到服务器。
3. **配置 Systemd 服务**(可选):
创建一个 `hua-todo.service` 文件,指向可执行程序并设置工作目录。
***
## 5. ⚙️ 关键配置校准
### 5.1 数据持久化
- **SQLite 路径**:客户端数据默认存储在用户目录下的 `Hua.Todo/todo.db`;服务端数据存储在容器挂载卷或指定 `appsettings.json` 的连接字符串中。
- **备份建议**:定期备份 `todo.db` 文件。
### 5.2 端口与访问
- **客户端本地端口**:默认 `5057`。如果被占用,可在 `appsettings.json` 中修改 `WebServer.HostUrl`。
- **服务端访问**:确保服务端防火墙已开放对应的 API 端口(默认 5173)。
***
## 6. 🛠️ 常见问题排查
- **客户端无法加载 UI**:检查目标机器是否安装了 WebView2 RuntimeWindows)或 WebKitGTKLinux)。
- **同步连接失败**
1. 确认服务端 API 是否可达(访问 `/swagger` 验证)。
2. 确认客户端配置的服务端地址格式(需包含 `http://` 或 `https://`)。
- **权限问题**:在 Linux 下运行前,务必执行 `chmod +x` 给主程序执行权限。
@@ -0,0 +1,36 @@
# 解决方案:统一版本与打包分发
## 现状分析
- **版本不一致**`Hua.Todo.Maui` 的版本号为 `1.2.3`,而 `Hua.Todo.Avalonia``Hua.Todo.Host` 默认使用 `1.0.0`
- **打包脚本缺失**:目前仅 `Hua.Todo.Maui` 拥有 `setup.iss` (Inno Setup) 打包脚本,版本号硬编码为 `1.2.2`
- **配置冗余**:版本信息分散在各个 `.csproj``.iss` 文件中,维护困难。
## 目标
- 统一所有项目的版本号为 `1.2.3`
- 采用 `Directory.Build.props` 集中管理全局属性。
-`Hua.Todo.Avalonia` 提供与 `Hua.Todo.Maui` 一致的 Windows 安装程序打包能力。
## 实施方案
### 1. 统一 .NET 项目版本号
- 在项目根目录创建 `Directory.Build.props` 文件。
- 定义全局版本号 `<Version>1.2.3</Version>`
- 定义全局公司、版权、产品名称等元数据。
- 移除各 `.csproj` 中冲突的版本定义。
### 2. 统一 Inno Setup 打包脚本
- **更新 `src/Hua.Todo.Maui/setup.iss`**
- 将版本号更新为 `1.2.3`
- 确保安装路径与应用名称一致。
- **为 `src/Hua.Todo.Avalonia` 创建 `setup.iss`**
- 参考 Maui 的脚本,调整 `Source` 路径为 Avalonia 的发布路径:`bin\Release\net10.0\win-x64\publish\*`
- 调整可执行文件名称为 `Hua.Todo.Avalonia.exe`
- 调整 AppId 以避免与 Maui 版本冲突。
### 3. 发布流程标准化
- 以后发布时,只需修改根目录下的 `Directory.Build.props` 即可同步所有项目的版本。
- 执行 `dotnet publish -c Release -r win-x64` 后,手动或通过脚本运行 `ISCC setup.iss` 生成安装包。
## 预期效果
- 所有程序集(DLL/EXE)的版本号均显示为 `1.2.3`
- 提供 `Hua.Todo_Avalonia_Setup_v1.2.3.exe``Hua.Todo_Maui_Setup_v1.2.3.exe` 两个安装包。
@@ -120,6 +120,8 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
## 关键实现点(建议)
详细实现方案请参考:[06.1-CloudSync-服务端安全设计方案.md](file:///d:/Proj/6.Hua.Todo/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md)
1. 用户隔离
- 服务端所有读写必须绑定当前登录用户上下文
2. 权限模型(RBAC
@@ -13,7 +13,8 @@
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
@@ -0,0 +1,181 @@
# 06.1 - 云同步(安全进阶):服务端账户/凭据与策略实现方案
## 1. 目标
`04-*`(基础能力)与 `06-*`(落盘策略)提供服务端底层安全实现的具体方案,确保账户密码存储、Token 管理、二次认证及安全策略下发具备生产级安全性。
## 2. 账户与密码安全存储
服务端不应存储明文密码,需采用强哈希算法进行加盐处理。
### 存储结构 (Users 表)
- `UserId`: `Guid` (主键)
- `UserName`: `string` (唯一索引)
- `PasswordHash`: `string` (存储哈希后的结果)
- `PasswordSalt`: `string` (若哈希算法自带 Salt,如 BCrypt/Argon2,则可省略)
- `Role`: `string` (用户角色,如 admin/user)
- `CreatedAtUtc`: `DateTime`
- `UpdatedAtUtc`: `DateTime`
### 哈希算法建议
- **算法**: `Argon2id` (推荐) 或 `BCrypt`
- **配置**: 迭代次数、内存占用、并行度应符合 OWASP 最新建议。
***
## 3. Token 管理 (JWT)
采用 JSON Web Token (JWT) 作为会话凭据,并支持细粒度权限控制。
### Token 结构 (Payload)
```json
{
"sub": "UserId",
"name": "UserName",
"role": "Role",
"perms": ["tasks:read", "sync:write", "policy:read"],
"iat": 1712000000,
"exp": 1712003600,
"iss": "Hua.Todo.Server",
"aud": "Hua.Todo.Client",
"isStepUp": false // 是否通过了二次认证
}
```
### 校验逻辑
- 每次请求需携带 `Authorization: Bearer {token}`
- 服务端验证签名、有效期(exp)、签发者(iss)及受众(aud)。
- 权限校验:中间件检查 `perms` 声明是否包含当前接口所需的权限。
***
## 4. 二次认证与会话提升 (Step-up)
针对高风险操作(如 `sync:write``policy:write`),要求会话必须处于“提升状态”。
### 流程设计
1. **触发**: 客户端调用 `/sync`,服务端检查 Token 的 `isStepUp` 声明。
2. **拒绝**: 若 `isStepUp == false`,返回 `403 SECOND_FACTOR_REQUIRED`
3. **挑战**: 客户端调用 `/auth/step-up`,提供二次认证凭据(如再次输入密码,或 TOTP 验证码)。
4. **提升**: 验证成功后,服务端颁发一个新的 Token,其中 `isStepUp: true`,且有效期较短(如 30 分钟)。
5. **重试**: 客户端携带新 Token 重新发起请求。
***
## 5. 安全策略 (Security Policy) 存储与下发
安全策略决定了客户端的“落盘”行为及二次认证频率。
### 策略定义 (SecurityPolicies 表)
- `Id`: `int` (主键)
- `UserId`: `Guid` (外键)
- `AllowPersist`: `bool` (是否允许客户端持久化任务数据)
- `AllowSync`: `bool` (是否允许该用户同步)
- `SecondFactorExpiryMinutes`: `int` (二次认证状态保持时长,默认 30)
- `IsTrustedDeviceOnly`: `bool` (是否仅限受信任终端)
### 下发逻辑
- 客户端通过 `GET /security/policy` 获取。
- 策略应与 `UserId` 绑定,允许管理员针对不同用户/角色进行差异化配置。
***
## 6. 审计日志 (Audit Logs)
记录关键安全事件,便于追溯。
- 登录成功/失败(记录 IP、终端信息)。
- 高风险操作尝试(是否通过二次认证)。
- 安全策略变更。
***
## 8. 客户端 UI 与交互设计
### 8.1 二次认证 (Step-up) 交互流程
1. **静默拦截**: 当用户触发高风险操作(如点击“立即同步”且 Token 已过期或未提升)时,客户端拦截请求并检测到 `403 SECOND_FACTOR_REQUIRED`
2. **弹出对话框**: 弹出“安全验证”模态框。
- **标题**: 需要二次认证。
- **描述**: “为了保护您的数据安全,执行此操作需要验证身份。”
- **输入**: 密码输入框(或 TOTP 验证码输入框)。
- **操作**: \[取消] \[验证并继续]。
3. **状态保持**: 验证成功后,UI 应显示短暂的“验证成功”提示,并自动重试刚才被拦截的操作。
### 8.2 客户端 UI (针对普通用户)
在客户端“设置 -> 云同步 -> 安全”路径下:
- **落盘策略 (AllowPersist)**:
- **展示**: 状态开关(Toggle)。
- **交互**:
- 若由服务端强制禁止,开关应为禁用状态(Disabled),并附带说明:“受服务端策略限制,当前终端禁止落盘”。
- 若允许修改,关闭开关时应弹出强提醒:“关闭后,所有本地任务数据将被立即清除,退出应用后数据将不再保留。确定继续吗?”
- **二次认证频率**:
- **展示**: 显示当前二次认证的有效期(如 30 分钟)。
### 8.3 “不可落盘”模式下的视觉提示
`allowPersist == false` 时:
- **状态栏/标题栏**: 增加“无痕模式”或“内存存储”小图标/文字提醒。
- **登录页**: 增加提醒:“当前环境配置为禁止落盘,数据仅在本次运行期间有效”。
- **退出应用**: 点击退出时,若有未同步数据,强提醒:“数据未同步且本地禁止落盘,退出将导致数据丢失。确定退出吗?”
***
## 9. 服务端管理后台 (Admin Dashboard)
为了避免直接操作数据库,服务端需提供一套 Web 管理后台,供管理员维护账户、权限与安全策略。
### 9.1 工程位置与实现
- **宿主项目**: [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host)
- **实现方式**:
- 前端采用 **Vue 3 + Vite** 开发。
- 静态资源在构建后嵌入到 `Hua.Todo.Host``wwwroot` 目录或作为内嵌资源。
- 后端通过 ASP.NET Core 的 `UseStaticFiles()` 托管,并确保 `/admin` 路由指向前端入口。
### 9.2 系统初始化 (Bootstrap)
- **触发条件**: 当检测到数据库中无任何用户时,访问 `/admin` 自动跳转至 `/admin/bootstrap`
- **功能**: 创建首个“超级管理员”账号。
- **UI**: 简单的表单(用户名、密码、确认密码)。
### 9.3 用户管理 (User Management)
- **用户列表**: 展示所有已注册用户(UserId, UserName, Role, CreatedAt)。
- **创建用户**: 管理员手动添加用户(用于内部系统或受控注册)。
- **重置密码**: 为忘记密码的用户生成临时密码或直接重设(需审计记录)。
- **禁用/删除**: 软删除或禁用账号。
### 9.4 安全策略配置 (Security Policy Management)
- **全局策略**: 设置系统默认的 `allowPersist``allowSync``SecondFactorExpiry`
- **单用户覆盖**: 在用户详情页中,管理员可以针对特定高风险用户/终端覆盖全局策略(例如:对特定外包人员账号强制 `allowPersist = false`)。
### 9.5 会话与凭据管理 (Session Management)
- **在线会话查看**: 展示当前所有有效的 Token/会话(记录 IP、最后活跃时间、是否已提升权限)。
- **强制下线 (Revoke)**: 管理员可一键吊销特定用户的所有 Token(将其加入黑名单)。
### 9.6 审计日志查看器 (Audit Log Viewer)
- **过滤查询**: 按时间、用户、事件类型(登录、同步、策略变更)过滤。
- **高亮显示**: 对“二次认证失败”、“异常 IP 登录”等敏感事件进行红色高亮。
***
## 10. 约束与风险
- **Token 吊销**: 默认 JWT 是无状态的。若需支持强制下线,需引入 Redis/DB 黑名单。
- **HTTPS**: 所有安全通信必须基于 TLS,防止中间人攻击窃取 Token。
- **暴力破解**: 需在 `POST /auth/login` 接口增加限流(Rate Limiting)机制。
@@ -21,19 +21,19 @@
## 验收清单(建议逐项勾选)
### Linux
- [ ] Linux 上可启动并打开主界面
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
- [x] Linux 上可启动并打开主界面
- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [x] 前端能成功请求本地 `/api/*` 并加载任务列表
- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md
### Search
- [ ] 主界面有搜索框
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [ ] 清空后恢复原列表
- [x] 主界面有搜索框
- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [x] 清空后恢复原列表
### 云同步(基础可用)
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
- [ ] 登录后能拉取该用户任务并展示
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [ ] 高风险操作触发二次认证(服务端支持时)
- [x] 可手动配置服务端地址,并有基础校验与风险提示
- [x] 登录后能拉取该用户任务并展示
- [x] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [x] 高风险操作触发二次认证(服务端支持时)
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Hua.Todo AppRun script for AppImage
HERE=$(dirname $(readlink -f $0))
export LD_LIBRARY_PATH=$HERE/usr/lib:$LD_LIBRARY_PATH
export PATH=$HERE/usr/bin:$PATH
exec "$HERE/Hua.Todo.Avalonia" "$@"
+39
View File
@@ -0,0 +1,39 @@
# Hua.Todo Linux Packaging (v1.2.0+)
本项目提供 Linux 平台的交付产物支持。目前在 Windows 构建环境下产出 `.tar.gz` 压缩包。
## 1. AppImage 打包 (Recommended)
AppImage 是 Linux 下主流的自包含、即插即用发布格式。
### 前提条件
- 在 Linux (Ubuntu/Debian 等) 环境下执行。
- 安装 `appimagetool`
### 制作步骤
1. 解压 `hua.todo-{version}-linux-x64.tar.gz``AppDir` 目录。
2.`pack/linux/AppRun` 复制到 `AppDir/AppRun` 并赋予执行权限。
3.`pack/linux/hua.todo.desktop` 复制到 `AppDir/hua.todo.desktop`
4. 将图标 `src/Hua.Todo.Avalonia/icon.ico` (或 png 版本) 复制到 `AppDir/appicon.png`
5. 执行打包命令:
```bash
appimagetool AppDir/ Hua.Todo-x86_64.AppImage
```
## 2. Flatpak 打包
Flatpak 提供更好的沙盒隔离与应用商店分发支持。
### 前提条件
- 安装 `flatpak-builder`。
### 制作步骤
1. 根据 `pack/linux/com.hua.todo.json` (待完善) 的描述配置 manifest。
2. 使用 `flatpak-builder` 构建。
## 3. 直接分发 (.tar.gz)
这是目前 `publish-linux.ps1` 默认产出的格式。
1. 解压后直接运行 `Hua.Todo.Avalonia` 即可。
2. 依赖项:`libwebkit2gtk-4.0-37` (用于 WebView)。
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Hua.Todo
Comment=Hua.Todo - Cross-platform Todo App (Avalonia)
Exec=AppRun
Icon=appicon
Type=Application
Categories=Office;Utility;
Terminal=false
X-AppImage-Name=Hua.Todo
X-AppImage-Version=1.2.8
+109 -12
View File
@@ -7,21 +7,31 @@
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
#>
param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[string]$TargetFramework = "net10.0",
[switch]$SelfContained,
[string]$Configuration = "Release"
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Get-FirstExistingFilePath {
param(
@@ -41,10 +51,23 @@ function Get-FirstExistingFilePath {
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
[string]$ProjectFile,
[string]$DirectoryBuildProps
)
$currentVersion = "0.0.0"
# 1. Try Directory.Build.props first
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
}
# 2. Try .csproj
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
@@ -60,6 +83,38 @@ function Read-ProjectVersion {
return $currentVersion
}
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2 # Give OS more time to release file handles
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
if (!$SkipProcessStop.IsPresent) {
Stop-ProjectProcesses
}
$candidateProjects = @(
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj")
@@ -77,7 +132,8 @@ $candidatesText
exit 1
}
$version = Read-ProjectVersion -ProjectFile $ProjectFile
$version = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier"
$publishDir = Join-Path $artifactRoot "publish"
@@ -88,16 +144,57 @@ New-Item -ItemType Directory -Path $publishDir | Out-Null
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
dotnet publish $ProjectFile `
-c $Configuration `
-r $RuntimeIdentifier `
--self-contained $selfContainedValue `
-o $publishDir
if (!$SkipRestore.IsPresent) {
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
# Ensure trailing slash and avoid backslash escaping the quote in CLI
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @restoreArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed"
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @cleanArgs
}
$maxAttempts = 3
$publishSuccess = $false
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Publishing (RID=$RuntimeIdentifier, TFM=$TargetFramework, SelfContained=$selfContainedValue, Config=$Configuration, Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$publishArgs = @("publish", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "--framework", $TargetFramework, "--self-contained", $selfContainedValue, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "-o", $publishDir)
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
}
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
$publishSuccess = $true
break
}
Write-Host $publishOutput
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
Stop-ProjectProcesses # Try killing processes again before retry
continue
}
Write-Error "dotnet publish failed after $attempt attempts."
exit 1
}
+154 -29
View File
@@ -1,32 +1,64 @@
param(
[ValidateSet("Maui", "Avalonia")]
[string]$AppType = "Maui",
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
[string]$RuntimeIdentifier = "win-x64",
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipInnoSetup,
[switch]$SkipVersionBump,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier))
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)),
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
if ($AppType -eq "Avalonia") {
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Avalonia"
$TargetFramework = "net10.0"
} else {
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
}
$ProjectFile = Get-ChildItem -Path $ProjectDir -Filter "*.csproj" | Select-Object -First 1 -ExpandProperty FullName
$SetupScript = Join-Path $ProjectDir "setup.iss"
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
[string]$ProjectFile,
[string]$DirectoryBuildProps
)
$currentVersion = "0.0.0"
# 1. Try Directory.Build.props first
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
}
# 2. Try .csproj
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
@@ -153,14 +185,93 @@ function Copy-Directory {
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2 # Give OS more time to release file handles
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
if (!$SkipProcessStop.IsPresent) {
Stop-ProjectProcesses
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion
Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
if (!$SkipRestore.IsPresent) {
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
# Ensure trailing slash and avoid backslash escaping the quote in CLI
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @restoreArgs
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @cleanArgs
}
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
$maxAttempts = 3
$publishSuccess = $false
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Publishing $ProjectBaseName (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$publishArgs = @("publish", $ProjectFile, "--framework", $TargetFramework, "-c", $Configuration, "-r", $RuntimeIdentifier, "--self-contained", "false", "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
}
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
$publishSuccess = $true
break
}
Write-Host $publishOutput
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
Stop-ProjectProcesses # Try killing processes again before retry
continue
}
Write-Error "MAUI build failed after $attempt attempts."
exit 1
}
@@ -192,6 +303,7 @@ if (!(Test-Path $desiredExePath)) {
$candidateExe = $null
$preferredCandidateNames = @(
"Hua.Todo.Maui.exe",
"Hua.Todo.Avalonia.exe",
"Hua.Todo.exe"
)
foreach ($name in $preferredCandidateNames) {
@@ -217,19 +329,21 @@ if (!(Test-Path $desiredExePath)) {
exit 1
}
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
exit 1
}
if ($AppType -eq "Maui") {
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
exit 1
}
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
Remove-Item -Recurse -Force $publishWwwroot
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
Remove-Item -Recurse -Force $publishWwwroot
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
$installerPath = $null
if (!$SkipInnoSetup.IsPresent) {
@@ -255,6 +369,7 @@ if (!$SkipInnoSetup.IsPresent) {
# ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly).
$maxAttempts = 3
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Compiling installer (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
@@ -286,8 +401,7 @@ if (!$SkipInnoSetup.IsPresent) {
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
}
} else {
Write-Error "Inno Setup compiler not found"
exit 1
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
}
}
@@ -302,7 +416,8 @@ if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
}
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
$installerName = "hua.todo-$currentVersion-$RuntimeIdentifier-setup.exe"
$installerPrefix = if ($AppType -eq "Avalonia") { "hua.todo-avalonia" } else { "hua.todo-maui" }
$installerName = "$installerPrefix-$currentVersion-$RuntimeIdentifier-setup.exe"
Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force
}
}
@@ -312,12 +427,22 @@ if (!$SkipVersionBump.IsPresent) {
if ($versionMatch.Success) {
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
$content = Get-Content $ProjectFile -Raw
if ($content -match "<Version>[^<]*</Version>") {
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content -Encoding UTF8
} else {
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
# 1. Update Directory.Build.props if exists
if (Test-Path $DirectoryBuildProps) {
$propsContent = Get-Content $DirectoryBuildProps -Raw
if ($propsContent -match "<Version>[^<]*</Version>") {
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
Write-Host "Updated version in Directory.Build.props to $newVersion" -ForegroundColor Green
}
}
# 2. Update .csproj if it still has Version
$csprojContent = Get-Content $ProjectFile -Raw
if ($csprojContent -match "<Version>[^<]*</Version>") {
$csprojContent = $csprojContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $csprojContent -Encoding UTF8
Write-Host "Updated version in $ProjectBaseName.csproj to $newVersion" -ForegroundColor Green
}
} else {
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
+111 -27
View File
@@ -7,6 +7,7 @@ param(
[switch]$LinuxSelfContained,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
@@ -17,9 +18,92 @@ param(
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$WebDir = Join-Path $ScriptPath "src\Hua.Todo.Web"
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
function Build-Web {
Write-Host "Building Web artifacts..." -ForegroundColor Cyan
if (!(Test-Path $WebDir)) {
Write-Error "Web directory not found: $WebDir"
exit 1
}
if (!(Test-Path (Join-Path $WebDir "node_modules"))) {
Write-Host "Installing npm dependencies..." -ForegroundColor Yellow
Push-Location $WebDir
npm install
Pop-Location
}
Write-Host "Running Web builds in parallel..." -ForegroundColor Yellow
$webJobs = @()
$webJobs += Start-Job -ScriptBlock {
param($dir)
Set-Location $dir
npm run build
} -ArgumentList $WebDir
$webJobs += Start-Job -ScriptBlock {
param($dir)
Set-Location $dir
npm run build -- --mode maui
} -ArgumentList $WebDir
$webJobs | Wait-Job | Receive-Job
}
function Bump-Version {
if (Test-Path $DirectoryBuildProps) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
$currentVersion = $versionNode.InnerText.Trim()
$versionMatch = [regex]::Match($currentVersion, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$')
if ($versionMatch.Success) {
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
$propsContent = Get-Content $DirectoryBuildProps -Raw
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
Write-Host "Bumped version in Directory.Build.props: $currentVersion -> $newVersion" -ForegroundColor Green
}
}
}
}
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
$shouldPublishWindows = $Windows.IsPresent
$shouldPublishLinux = $Linux.IsPresent
@@ -28,36 +112,36 @@ if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishLinux = $true
}
# 1. Cleanup
Stop-ProjectProcesses
# 2. Build Web
Build-Web
# 3. App Publishing
# NOTE: To avoid file locks in 'obj' and 'bin' folders, we publish the .NET apps sequentially.
# However, we can parallelize the Inno Setup packaging later if needed.
Write-Host "Starting app publishing..." -ForegroundColor Cyan
if ($shouldPublishWindows) {
if (!(Test-Path $publishWindowsScript)) {
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
exit 1
}
& $publishWindowsScript `
-Configuration $Configuration `
-SkipInnoSetup:$SkipWindowsInnoSetup `
-SkipVersionBump:$SkipWindowsVersionBump
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host "Publishing Windows Apps..." -ForegroundColor Cyan
# Maui Windows (Skip ISS here, we'll do it later if parallel is needed, or just let it run)
& $publishWindowsScript -AppType Maui -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
# Avalonia Windows
& $publishWindowsScript -AppType Avalonia -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
}
if ($shouldPublishLinux) {
if (!(Test-Path $publishLinuxScript)) {
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
exit 1
}
Write-Host "Publishing Linux Apps..." -ForegroundColor Cyan
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript `
-RuntimeIdentifier $rid `
-SelfContained:$LinuxSelfContained `
-Configuration $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
& $publishLinuxScript -RuntimeIdentifier $rid -SelfContained:$LinuxSelfContained -Configuration $Configuration -SkipProcessStop
}
}
# 4. Finalizing
if (!$SkipWindowsVersionBump.IsPresent) {
Bump-Version
}
Write-Host "All publishing tasks completed!" -ForegroundColor Green
@@ -21,24 +21,41 @@ public static class CloudSyncEndpointExtensions
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read");
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).AllowAnonymous();
sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write");
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
security.MapGet("/policy", GetPolicyAsync).RequireAuthorization("policy:read");
security.MapPut("/policy", UpdatePolicyAsync).RequireAuthorization("policy:write");
var admin = app.MapGroup("/admin").WithTags("CloudSync - Admin").RequireAuthorization("users:manage");
admin.MapGet("/users", GetUsersAsync);
admin.MapPost("/users", CreateUserAsync);
admin.MapPost("/users/{userId}/reset-password", ResetPasswordAsync);
admin.MapDelete("/users/{userId}", DeleteUserAsync);
admin.MapGet("/sessions", GetSessionsAsync);
admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync);
admin.MapGet("/audit-logs", GetAuditLogsAsync);
return app;
}
private static (string? ip, string? ua) GetClientInfo(HttpContext httpContext)
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString();
var ua = httpContext.Request.Headers.UserAgent.ToString();
return (ip, ua);
}
private static async Task<IResult> BootstrapAdminAsync(
BootstrapAdminRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
@@ -46,7 +63,8 @@ public static class CloudSyncEndpointExtensions
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken);
var (ip, ua) = GetClientInfo(httpContext);
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken);
if (!ok)
{
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
@@ -58,6 +76,7 @@ public static class CloudSyncEndpointExtensions
private static async Task<IResult> LoginAsync(
LoginRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
@@ -65,7 +84,8 @@ public static class CloudSyncEndpointExtensions
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken);
var (ip, ua) = GetClientInfo(httpContext);
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, cancellationToken);
if (response == null)
{
return CloudApiErrors.Unauthorized("Invalid credentials.");
@@ -91,7 +111,8 @@ public static class CloudSyncEndpointExtensions
return CloudApiErrors.BadRequest("Password is required.");
}
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken);
var (ip, ua) = GetClientInfo(httpContext);
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, ip, ua, cancellationToken);
if (!expiresAt.HasValue)
{
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
@@ -188,7 +209,108 @@ public static class CloudSyncEndpointExtensions
return CloudApiErrors.SecondFactorRequired();
}
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken);
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.SecondFactorExpiryMinutes, request.IsTrustedDeviceOnly, cancellationToken);
return Results.Json(policy);
}
#region Admin Endpoints
private static async Task<IResult> GetUsersAsync(
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// TODO: 启用权限校验
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
var users = await adminService.GetUsersAsync(cancellationToken);
return Results.Json(users);
}
private static async Task<IResult> CreateUserAsync(
CreateUserRequest request,
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var user = await adminService.CreateUserAsync(request, cancellationToken);
if (user == null) return CloudApiErrors.BadRequest("User already exists or creation failed.");
return Results.Json(user);
}
private static async Task<IResult> ResetPasswordAsync(
Guid userId,
ResetPasswordRequest request,
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
if (request == null || string.IsNullOrWhiteSpace(request.NewPassword))
{
return CloudApiErrors.BadRequest("NewPassword is required.");
}
var ok = await adminService.ResetPasswordAsync(userId, request.NewPassword, cancellationToken);
return ok ? Results.Ok() : CloudApiErrors.NotFound();
}
private static async Task<IResult> DeleteUserAsync(
Guid userId,
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
var ok = await adminService.DeleteUserAsync(userId, cancellationToken);
return ok ? Results.Ok() : CloudApiErrors.BadRequest("User not found or deletion not allowed.");
}
private static async Task<IResult> GetSessionsAsync(
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
var sessions = await adminService.GetSessionsAsync(cancellationToken);
return Results.Json(sessions);
}
private static async Task<IResult> RevokeSessionAsync(
Guid sessionId,
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
var ok = await adminService.RevokeSessionAsync(sessionId, cancellationToken);
return ok ? Results.Ok() : CloudApiErrors.NotFound();
}
private static async Task<IResult> GetAuditLogsAsync(
int count,
CloudAdminService adminService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
if (count <= 0) count = 100;
var logs = await adminService.GetAuditLogsAsync(count, cancellationToken);
return Results.Json(logs);
}
#endregion
}
@@ -25,6 +25,7 @@ public static class CloudSyncServiceCollectionExtensions
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudAdminService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
@@ -0,0 +1,60 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 用户信息 DTO(管理端使用)。
/// </summary>
public class UserDto
{
public Guid Id { get; set; }
public string UserName { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
}
/// <summary>
/// 创建用户请求。
/// </summary>
public class CreateUserRequest
{
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Role { get; set; } = "user";
}
/// <summary>
/// 重置密码请求。
/// </summary>
public class ResetPasswordRequest
{
public string NewPassword { get; set; } = string.Empty;
}
/// <summary>
/// 审计日志 DTO。
/// </summary>
public class AuditLogDto
{
public Guid Id { get; set; }
public DateTime TimestampUtc { get; set; }
public Guid? UserId { get; set; }
public string? UserName { get; set; }
public string EventType { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? ClientIp { get; set; }
public string? UserAgent { get; set; }
public bool IsSuccess { get; set; }
}
/// <summary>
/// 会话信息 DTO。
/// </summary>
public class SessionDto
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
public DateTime ExpiresAtUtc { get; set; }
public bool IsSteppedUp { get; set; }
}
@@ -19,6 +19,16 @@ public class SecurityPolicyDto
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
/// <summary>
@@ -35,4 +45,14 @@ public class UpdateSecurityPolicyRequest
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
@@ -0,0 +1,148 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 管理端服务(账户、会话、审计、全局策略管理)。
/// </summary>
public class CloudAdminService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
public CloudAdminService(TodoDbContext dbContext, IPasswordHasher<UserEntity> passwordHasher)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
}
#region User Management
public async Task<List<UserDto>> GetUsersAsync(CancellationToken cancellationToken)
{
return await _dbContext.Users
.AsNoTracking()
.Where(u => u.Role != "local")
.Select(u => new UserDto
{
Id = u.Id,
UserName = u.UserName,
Role = u.Role,
CreatedAtUtc = u.CreatedAtUtc,
UpdatedAtUtc = u.UpdatedAtUtc
})
.ToListAsync(cancellationToken);
}
public async Task<UserDto?> CreateUserAsync(CreateUserRequest request, CancellationToken cancellationToken)
{
var normalizedUserName = request.UserName.Trim();
var exists = await _dbContext.Users.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists) return null;
var now = DateTime.UtcNow;
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = request.Role,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
_dbContext.Users.Add(user);
// 为新用户创建默认策略
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id });
await _dbContext.SaveChangesAsync(cancellationToken);
return new UserDto { Id = user.Id, UserName = user.UserName, Role = user.Role, CreatedAtUtc = user.CreatedAtUtc, UpdatedAtUtc = user.UpdatedAtUtc };
}
public async Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
{
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return false;
user.PasswordHash = _passwordHasher.HashPassword(user, newPassword);
user.UpdatedAtUtc = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null || user.Role == "admin") return false; // 不允许通过 API 删除最后一个 admin(通常应有更多保护)
_dbContext.Users.Remove(user);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
#endregion
#region Session Management
public async Task<List<SessionDto>> GetSessionsAsync(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
return await _dbContext.UserSessions
.AsNoTracking()
.Include(s => s.User)
.Where(s => s.ExpiresAtUtc > now)
.Select(s => new SessionDto
{
Id = s.Id,
UserId = s.UserId,
UserName = s.User != null ? s.User.UserName : "Unknown",
CreatedAtUtc = s.CreatedAtUtc,
ExpiresAtUtc = s.ExpiresAtUtc,
IsSteppedUp = s.StepUpExpiresAtUtc.HasValue && s.StepUpExpiresAtUtc.Value > now
})
.ToListAsync(cancellationToken);
}
public async Task<bool> RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken)
{
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null) return false;
_dbContext.UserSessions.Remove(session);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
#endregion
#region Audit Logs
public async Task<List<AuditLogDto>> GetAuditLogsAsync(int count, CancellationToken cancellationToken)
{
return await _dbContext.AuditLogs
.AsNoTracking()
.OrderByDescending(l => l.TimestampUtc)
.Take(count)
.Select(l => new AuditLogDto
{
Id = l.Id,
TimestampUtc = l.TimestampUtc,
UserId = l.UserId,
UserName = l.UserName,
EventType = l.EventType,
Description = l.Description,
ClientIp = l.ClientIp,
UserAgent = l.UserAgent,
IsSuccess = l.IsSuccess
})
.ToListAsync(cancellationToken);
}
#endregion
}
@@ -32,14 +32,42 @@ public class CloudAuthService
_rolePermissionMapper = rolePermissionMapper;
}
private async Task LogAsync(
string eventType,
string description,
Guid? userId = null,
string? userName = null,
bool isSuccess = true,
string? clientIp = null,
string? userAgent = null)
{
var log = new AuditLogEntity
{
Id = Guid.NewGuid(),
TimestampUtc = DateTime.UtcNow,
EventType = eventType,
Description = description,
UserId = userId,
UserName = userName,
IsSuccess = isSuccess,
ClientIp = clientIp,
UserAgent = userAgent
};
_dbContext.AuditLogs.Add(log);
await _dbContext.SaveChangesAsync();
}
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
public async Task<bool> BootstrapAdminAsync(string userName, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
@@ -53,6 +81,7 @@ public class CloudAuthService
if (hasAnyCloudUser)
{
await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return false;
}
@@ -65,11 +94,14 @@ public class CloudAuthService
return false;
}
var now = DateTime.UtcNow;
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin"
Role = "admin",
CreatedAtUtc = now,
UpdatedAtUtc = now
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
@@ -78,6 +110,7 @@ public class CloudAuthService
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return true;
}
@@ -87,9 +120,11 @@ public class CloudAuthService
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="sessionTtl">会话有效期。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录响应;失败则返回 null。</returns>
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
@@ -100,12 +135,14 @@ public class CloudAuthService
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (user == null || user.Role == "local")
{
await LogAsync("LoginFailed", "User not found or local user login attempted.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
await LogAsync("LoginFailed", "Invalid password.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return null;
}
@@ -122,17 +159,17 @@ public class CloudAuthService
_dbContext.UserSessions.Add(session);
var hasPolicy = await _dbContext.SecurityPolicies
.AsNoTracking()
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
if (!hasPolicy)
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (policy == null)
{
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true };
_dbContext.SecurityPolicies.Add(policy);
}
await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("LoginSuccess", "User logged in.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return new LoginResponse
{
AccessToken = session.Id.ToString(),
@@ -148,10 +185,11 @@ public class CloudAuthService
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</param>
/// <param name="stepUpTtl">二次认证有效期。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
@@ -175,13 +213,20 @@ public class CloudAuthService
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
await LogAsync("StepUpFailed", "Invalid password during step-up.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return null;
}
var stepUpExpiresAt = now.Add(stepUpTtl);
var policy = await _dbContext.SecurityPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
var expiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30;
if (expiryMinutes <= 0) expiryMinutes = 30;
var stepUpExpiresAt = now.AddMinutes(expiryMinutes);
session.StepUpExpiresAtUtc = stepUpExpiresAt;
await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("StepUpSuccess", $"Session stepped up for {expiryMinutes} minutes.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return stepUpExpiresAt;
}
}
@@ -37,7 +37,9 @@ public class SecurityPolicyService
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
};
}
@@ -46,20 +48,33 @@ public class SecurityPolicyService
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param>
/// <param name="allowSync">是否允许同步。</param>
/// <param name="secondFactorExpiryMinutes">二次认证有效期。</param>
/// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, int secondFactorExpiryMinutes, bool isTrustedDeviceOnly, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
if (policy == null)
{
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync };
policy = new SecurityPolicyEntity
{
Id = Guid.NewGuid(),
UserId = userId,
AllowPersist = allowPersist,
AllowSync = allowSync,
SecondFactorExpiryMinutes = secondFactorExpiryMinutes,
IsTrustedDeviceOnly = isTrustedDeviceOnly
};
_dbContext.SecurityPolicies.Add(policy);
}
else
{
policy.AllowPersist = allowPersist;
policy.AllowSync = allowSync;
policy.SecondFactorExpiryMinutes = secondFactorExpiryMinutes;
policy.IsTrustedDeviceOnly = isTrustedDeviceOnly;
}
await _dbContext.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,70 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Data.Converters;
/// <summary>
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601Round-trip)字符串形式持久化到 SQLite,
/// 并在读取时兼容历史遗留的 “ticks/Unix 时间戳” 数字字符串,避免因脏数据导致查询失败。
/// </summary>
public sealed class LenientUtcDateTimeStringConverter : ValueConverter<DateTime, string>
{
/// <summary>
/// 创建 UTC DateTime 与 SQLite TEXT 之间的转换器。
/// </summary>
public LenientUtcDateTimeStringConverter()
: base(
model => ConvertModelToProvider(model),
provider => ConvertProviderToModel(provider))
{
}
private static string ConvertModelToProvider(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return utc.ToString("O", CultureInfo.InvariantCulture);
}
private static DateTime ConvertProviderToModel(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
if (numeric >= DateTime.MinValue.Ticks && numeric <= DateTime.MaxValue.Ticks && numeric >= 10_000_000_000_000)
{
return new DateTime(numeric, DateTimeKind.Utc);
}
if (numeric >= 0 && numeric <= 253_402_300_799_999)
{
return DateTimeOffset.FromUnixTimeMilliseconds(numeric).UtcDateTime;
}
if (numeric >= 0 && numeric <= 253_402_300_799)
{
return DateTimeOffset.FromUnixTimeSeconds(numeric).UtcDateTime;
}
}
if (DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
}
+29 -4
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
using Hua.Todo.Application.Data.Converters;
namespace Hua.Todo.Application.Data;
@@ -36,6 +37,11 @@ public class TodoDbContext : DbContext
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 安全审计日志集合。
/// </summary>
public DbSet<AuditLogEntity> AuditLogs { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
@@ -44,6 +50,8 @@ public class TodoDbContext : DbContext
{
base.OnModelCreating(modelBuilder);
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("Tasks");
@@ -52,8 +60,8 @@ public class TodoDbContext : DbContext
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.CreatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.HasOne(e => e.User)
.WithMany(u => u.Tasks)
@@ -80,8 +88,9 @@ public class TodoDbContext : DbContext
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
entity.Property(e => e.CreatedAtUtc).IsRequired();
entity.Property(e => e.ExpiresAtUtc).IsRequired();
entity.Property(e => e.CreatedAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.ExpiresAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.StepUpExpiresAtUtc).HasConversion(utcDateTimeConverter);
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User)
.WithMany()
@@ -93,13 +102,29 @@ public class TodoDbContext : DbContext
{
entity.ToTable("SecurityPolicies");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserId).IsRequired();
entity.Property(e => e.AllowPersist).HasDefaultValue(true);
entity.Property(e => e.AllowSync).HasDefaultValue(true);
entity.Property(e => e.SecondFactorExpiryMinutes).HasDefaultValue(30);
entity.Property(e => e.IsTrustedDeviceOnly).HasDefaultValue(false);
entity.HasIndex(e => e.UserId).IsUnique();
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<AuditLogEntity>(entity =>
{
entity.ToTable("AuditLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.TimestampUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.EventType).IsRequired().HasMaxLength(64);
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.ClientIp).HasMaxLength(64);
entity.Property(e => e.UserAgent).HasMaxLength(500);
entity.HasIndex(e => e.UserId);
entity.HasIndex(e => e.TimestampUtc);
});
}
}
@@ -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;
/// <summary>
/// 为 Dynamic API<c>/api/{service}/...</c>)补齐 OpenAPI 文档的过滤器。
/// 说明:Dynamic API 通过中间件反射分发,不会被 ASP.NET Core 的 ApiExplorer 自动发现;
/// 因此需要在 Swagger 生成阶段手动把这些端点补充到 OpenAPI Paths 中。
/// </summary>
public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter
{
/// <summary>
/// 将 Dynamic API 端点追加到 <paramref name="swaggerDoc"/>。
/// </summary>
/// <param name="swaggerDoc">待输出的 OpenAPI 文档。</param>
/// <param name="context">Swagger 生成上下文(用于 Schema 生成与引用管理)。</param>
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<OpenApiTagReference> { 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<string, OpenApiMediaType>
{
["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<IOpenApiParameter>();
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<string, OpenApiMediaType>
{
["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<FromBodyAttribute>() != 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<string> ExtractRouteParameters(string routeSuffix)
{
var list = new List<string>();
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<HttpGetAttribute>()?.Route
?? method.GetCustomAttribute<HttpPostAttribute>()?.Route
?? method.GetCustomAttribute<HttpPutAttribute>()?.Route
?? method.GetCustomAttribute<HttpDeleteAttribute>()?.Route
?? method.GetCustomAttribute<HttpPatchAttribute>()?.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<RemoteServiceAttribute>();
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<HttpGetAttribute>() != null) return "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null) return "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null) return "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null) return "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != 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;
}
}
@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<!-- 当指定桌面平台 RID 或显式标记为桌面构建时,仅保留 net10.0 目标框架,避免还原阶段为移动端 TFM 解析 Mono runtime pack。 -->
<IsDesktopRID Condition="'$(IsDesktopBuild)' == 'true' or ('$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-'))))">true</IsDesktopRID>
<TargetFrameworks Condition="'$(IsDesktopRID)' == 'true'">net10.0</TargetFrameworks>
<TargetFrameworks Condition="'$(IsDesktopRID)' != 'true'">net10.0;net10.0-android36.0;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
@@ -9,6 +12,7 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
@@ -0,0 +1,219 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260413140347_UpdateSecurityEntities")]
partial class UpdateSecurityEntities
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class UpdateSecurityEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAtUtc",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAtUtc",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CreatedAtUtc",
table: "Users");
migrationBuilder.DropColumn(
name: "UpdatedAtUtc",
table: "Users");
migrationBuilder.DropColumn(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies");
migrationBuilder.DropColumn(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies");
}
}
}
@@ -0,0 +1,269 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260413140753_AddAuditLogs")]
partial class AddAuditLogs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TimestampUtc");
b.HasIndex("UserId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAuditLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: 30,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: false,
oldClrType: typeof(bool),
oldType: "INTEGER");
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
TimestampUtc = table.Column<string>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
UserName = table.Column<string>(type: "TEXT", nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ClientIp = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
IsSuccess = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_TimestampUtc",
table: "AuditLogs",
column: "TimestampUtc");
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_UserId",
table: "AuditLogs",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.AlterColumn<int>(
name: "SecondFactorExpiryMinutes",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 30);
migrationBuilder.AlterColumn<bool>(
name: "IsTrustedDeviceOnly",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
oldClrType: typeof(bool),
oldType: "INTEGER",
oldDefaultValue: false);
}
}
}
@@ -17,6 +17,52 @@ namespace Hua.Todo.Application.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TimestampUtc");
b.HasIndex("UserId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
@@ -33,6 +79,16 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -50,7 +106,8 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
@@ -73,7 +130,8 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
@@ -98,6 +156,9 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
@@ -107,6 +168,9 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
@@ -126,13 +190,15 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
-18
View File
@@ -2,27 +2,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Hua.Todo.Avalonia.App"
xmlns:local="using:Hua.Todo.Avalonia"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
x:DataType="vm:AppTrayViewModel"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="{Binding TrayTooltipText}"
Command="{Binding ShowMainWindowCommand}">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
+53 -15
View File
@@ -1,8 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using AvaloniaWebView;
using Hua.Todo.Avalonia.Models;
@@ -13,7 +13,6 @@ using Hua.Todo.Avalonia.Views;
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
@@ -98,9 +97,7 @@ public partial class App : global::Avalonia.Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
DisableAvaloniaDataAnnotationValidation();
_webServer = new EmbeddedWebServerService(_appSettings!);
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
_ = Task.Run(async () =>
{
try
@@ -132,10 +129,16 @@ public partial class App : global::Avalonia.Application
};
}
DataContext = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
// 只在支持的平台上初始化系统托盘
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
{
var trayViewModel = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
InitializeTrayIcon(trayViewModel);
}
_mainWindow = new MainWindow(_appSettings!, _webServer)
{
@@ -185,6 +188,12 @@ public partial class App : global::Avalonia.Application
}
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
singleView.MainView = new MainView(_appSettings!, _webServer);
}
base.OnFrameworkInitializationCompleted();
}
@@ -257,14 +266,43 @@ public partial class App : global::Avalonia.Application
}
}
private void DisableAvaloniaDataAnnotationValidation()
private void InitializeTrayIcon(AppTrayViewModel trayViewModel)
{
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
try
{
BindingPlugins.DataValidators.Remove(plugin);
var trayIcons = new TrayIcons();
var iconUri = new Uri("avares://Hua.Todo.Avalonia/icon.ico");
var icon = new WindowIcon(AssetLoader.Open(iconUri));
var trayIcon = new TrayIcon
{
Icon = icon,
ToolTipText = trayViewModel.TrayTooltipText,
Command = trayViewModel.ShowMainWindowCommand
};
var menu = new NativeMenu();
menu.Items.Add(new NativeMenuItem
{
Header = "显示主窗口",
Command = trayViewModel.ShowMainWindowCommand
});
menu.Items.Add(new NativeMenuItemSeparator());
menu.Items.Add(new NativeMenuItem
{
Header = "退出",
Command = trayViewModel.ExitApplicationCommand
});
trayIcon.Menu = menu;
trayIcons.Add(trayIcon);
TrayIcon.SetIcons(this, trayIcons);
}
catch (Exception ex)
{
Console.WriteLine($"[App] Tray icon initialization failed: {ex}");
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

+39 -7
View File
@@ -1,7 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('linux'))">net10.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">net10.0</TargetFrameworks>
<!-- 追加 Android 目标,作为未来的移动端主入口。 -->
<TargetFrameworks>$(TargetFrameworks);net10.0-android</TargetFrameworks>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
@@ -11,30 +17,56 @@
<ForceWebBuild>false</ForceWebBuild>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-android'">
<SupportedOSPlatformVersion>21.0</SupportedOSPlatformVersion>
<ApplicationId>com.hua.todo</ApplicationId>
<ApplicationTitle>代办</ApplicationTitle>
<AndroidApplication>true</AndroidApplication>
<OutputType>Exe</OutputType>
<!-- 彻底解决 Java.Lang.NoClassDefFoundError:
在 .NET 10 Preview 环境中,Fast Deployment 往往无法正确处理动态加载的 AndroidX 资源。
通过禁用 Fast Deployment 并强制将所有程序集嵌入 APK,可以解决此问题。 -->
<AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>False</AndroidEnableFastDeployment>
<EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="icon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
<PackageReference Include="Avalonia" Version="11.3.13" />
<PackageReference Include="Avalonia.Android" Version="11.3.13" Condition="'$(TargetFramework)' == 'net10.0-android'" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.13" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.13" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.13" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<!-- 增加 Android 下的 WebView 支持包,避免跨平台逻辑中断。 -->
<PackageReference Include="WebView.Avalonia.Android" Version="11.0.0.1" Condition="'$(TargetFramework)' == 'net10.0-android'" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.13">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<!-- 为了修复 NoClassDefFoundError,我们需要显式确保核心库在 AOT 或 裁剪时被保留。 -->
<PackageReference Include="Xamarin.AndroidX.AppCompat" Version="1.7.1.1" Condition="'$(TargetFramework)' == 'net10.0-android'" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.hua.todo">
<application android:allowBackup="true" android:icon="@drawable/icon" android:label="代办" android:supportsRtl="true">
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,24 @@
using Android.App;
using Android.Content.PM;
using Avalonia;
using Avalonia.Android;
namespace Hua.Todo.Avalonia.Platforms.Android;
/// <summary>
/// Avalonia Android 平台入口 Activity。
/// </summary>
[Activity(
Label = "代办",
Theme = "@style/AvaloniaMainActivityTheme",
Icon = "@drawable/icon",
MainLauncher = true,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity<App>
{
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont();
}
}
+10 -2
View File
@@ -1,5 +1,7 @@
using Avalonia;
using Avalonia.WebView.Desktop;
using Hua.Todo.Avalonia.Services;
using Hua.Todo.Avalonia.Models;
using System;
namespace Hua.Todo.Avalonia;
@@ -10,8 +12,14 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
// 设置嵌入式 Web 服务器工厂
EmbeddedWebServerServiceFactory.SetFactory(appSettings => new EmbeddedWebServerService(appSettings));
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" rx="88" ry="88" fill="#512BD4" />
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" fill="#ffffff" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" fill="#ffffff" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" fill="#ffffff" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary>
@@ -0,0 +1,434 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style>
<Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/>
</Style>
<Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Opacity" Value="0.5" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Shadow">
<Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" />
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
</ResourceDictionary>
@@ -0,0 +1,42 @@
using System;
using Hua.Todo.Avalonia.Models;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 嵌入式 WebServer 的平台工厂。
/// 由平台入口项目在启动时注入实际实现(例如 Desktop 使用 KestrelAndroid 可使用 Noop 或移动端实现),
/// 以避免共享项目直接依赖某个平台不可用的运行时组件。
/// </summary>
public static class EmbeddedWebServerServiceFactory
{
private static Func<AppSettings, IEmbeddedWebServerService>? _factory;
/// <summary>
/// 设置 WebServer 创建工厂。
/// 平台入口应尽早调用(在 <see cref="global::Avalonia.Application"/> 生命周期开始前完成),以确保应用启动过程可用。
/// </summary>
/// <param name="factory">根据应用配置创建 WebServer 的工厂方法。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="factory"/> 为 null。</exception>
public static void SetFactory(Func<AppSettings, IEmbeddedWebServerService> factory)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
/// <summary>
/// 创建 WebServer 实例。
/// 若平台未注入工厂,则回退为 <see cref="NoopEmbeddedWebServerService"/>(不启动本地服务,仅用于维持调用链)。
/// </summary>
/// <param name="appSettings">应用配置。</param>
/// <returns>嵌入式 WebServer 实例。</returns>
public static IEmbeddedWebServerService Create(AppSettings appSettings)
{
if (_factory != null)
{
return _factory(appSettings);
}
return new NoopEmbeddedWebServerService(appSettings.WebServer.HostUrl);
}
}
@@ -18,6 +18,14 @@ public static class GlobalHotKeyServiceFactory
{
return new WindowsGlobalHotKeyService();
}
else if (OperatingSystem.IsLinux())
{
return new LinuxGlobalHotKeyService();
}
else if (OperatingSystem.IsAndroid())
{
return new AndroidGlobalHotKeyService();
}
return new NoopGlobalHotKeyService();
}
@@ -0,0 +1,34 @@
using System.Threading.Tasks;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 不启动任何本地 WebServer 的占位实现。
/// 适用于移动端/受限平台:仍可复用 UI 与前端契约注入逻辑,但 WebView 需要指向外部可访问的前端地址。
/// </summary>
public sealed class NoopEmbeddedWebServerService : IEmbeddedWebServerService
{
/// <summary>
/// 创建 <see cref="NoopEmbeddedWebServerService"/>。
/// </summary>
/// <param name="baseUrl">
/// 逻辑上的基础 URL。该实现不会实际监听端口,但会把该值暴露给调用方用于日志/显示或兼容逻辑。
/// </param>
public NoopEmbeddedWebServerService(string baseUrl)
{
BaseUrl = baseUrl;
}
/// <inheritdoc />
public bool IsRunning => false;
/// <inheritdoc />
public string BaseUrl { get; }
/// <inheritdoc />
public Task StartAsync() => Task.CompletedTask;
/// <inheritdoc />
public Task StopAsync() => Task.CompletedTask;
}
@@ -0,0 +1,50 @@
using System;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Android 平台全局热键服务实现类
/// 由于 Android 限制全局热键,使用通知快捷方式作为替代方案
/// </summary>
public class AndroidGlobalHotKeyService : IGlobalHotKeyService
{
private Action? _callback;
/// <summary>
/// Android 平台不支持全局热键
/// </summary>
public bool IsSupported => false;
/// <summary>
/// 注册通知快捷方式作为热键替代方案
/// </summary>
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
/// <param name="key">主键(如 X、C)。</param>
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
public void RegisterHotKey(string modifiers, string key, Action callback)
{
_callback = callback;
Console.WriteLine($"[AndroidGlobalHotKeyService] Registering notification shortcut as hotkey alternative");
}
/// <summary>
/// 注销快捷方式(空实现)
/// </summary>
public void UnregisterHotKey()
{
Console.WriteLine("[AndroidGlobalHotKeyService] Unregistering hotkey");
}
/// <summary>
/// 更新快捷方式配置
/// </summary>
/// <param name="modifiers">新的修饰键。</param>
/// <param name="key">新的主键。</param>
public void UpdateHotKey(string modifiers, string key)
{
if (_callback != null)
{
RegisterHotKey(modifiers, key, _callback);
}
}
}
@@ -0,0 +1,81 @@
using System;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Linux 平台全局热键服务实现类
/// 提供基于 Linux 平台的全局热键支持
/// </summary>
public class LinuxGlobalHotKeyService : IGlobalHotKeyService
{
private Action? _callback;
private string? _modifiers;
private string? _key;
/// <summary>
/// Linux 平台支持全局热键
/// </summary>
public bool IsSupported => true;
/// <summary>
/// 注册全局热键
/// </summary>
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
/// <param name="key">主键(如 X、C)。</param>
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
public void RegisterHotKey(string modifiers, string key, Action callback)
{
try
{
_callback = callback;
_modifiers = modifiers;
_key = key;
// Linux 平台热键注册逻辑
// 这里可以使用如 libX11 或其他 Linux 热键库
// 由于 Avalonia 本身对 Linux 热键支持有限,这里提供基本实现
Console.WriteLine($"[LinuxGlobalHotKeyService] Registered hotkey: {modifiers}+{key}");
}
catch (Exception ex)
{
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to register hotkey: {ex.Message}");
}
}
/// <summary>
/// 注销已注册的热键
/// </summary>
public void UnregisterHotKey()
{
try
{
// Linux 平台热键注销逻辑
Console.WriteLine("[LinuxGlobalHotKeyService] Unregistered hotkey");
}
catch (Exception ex)
{
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to unregister hotkey: {ex.Message}");
}
}
/// <summary>
/// 更新热键配置
/// </summary>
/// <param name="modifiers">新的修饰键。</param>
/// <param name="key">新的主键。</param>
public void UpdateHotKey(string modifiers, string key)
{
try
{
UnregisterHotKey();
if (_callback != null)
{
RegisterHotKey(modifiers, key, _callback);
}
}
catch (Exception ex)
{
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to update hotkey: {ex.Message}");
}
}
}
@@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
x:Class="Hua.Todo.Avalonia.Views.MainView"
x:DataType="vm:MainWindowViewModel">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<WebView Name="MainWebView" />
</UserControl>
@@ -0,0 +1,113 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using System;
namespace Hua.Todo.Avalonia.Views;
/// <summary>
/// 应用主视图。
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
/// </summary>
public partial class MainView : UserControl
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService _webServer;
/// <summary>
/// 创建用于设计器预览的主视图实例。
/// </summary>
public MainView()
{
InitializeComponent();
_appSettings = new AppSettings();
_webServer = new NoopEmbeddedWebServerService(_appSettings.WebServer.HostUrl);
}
/// <summary>
/// 创建运行时主视图实例。
/// </summary>
/// <param name="appSettings">应用配置。</param>
/// <param name="webServer">嵌入式 WebServer。</param>
public MainView(AppSettings appSettings, IEmbeddedWebServerService webServer)
{
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebView();
}
private void SetupWebView()
{
try
{
var webUrl = ResolveWebUrl();
MainWebView.Url = webUrl;
MainWebView.NavigationCompleted += async (_, _) =>
{
try
{
if (_webServer is not NoopEmbeddedWebServerService)
{
var apiBase = $"{_appSettings.WebServer.HostUrl.TrimEnd('/')}/api";
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.ExecuteScriptAsync(@"
window.mauiInterop = {
onHotKeyConfigUpdated: null,
openHotKeySettings: function(config) {
const event = new CustomEvent('openHotKeySettings', { detail: config });
window.dispatchEvent(event);
},
updateHotKeyConfig: function(modifiers, key, isEnabled) {
const event = new CustomEvent('updateHotKeyConfig', {
detail: { modifiers, key, isEnabled }
});
window.dispatchEvent(event);
}
};
window.addEventListener('hotKeyConfigChanged', function(e) {
if (window.mauiInterop.onHotKeyConfigUpdated) {
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
}
});
");
}
catch (Exception ex)
{
Console.WriteLine($"[MainView] WebView script injection failed: {ex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[MainView] WebView initialization failed: {ex}");
Content = new TextBlock
{
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
}
private Uri ResolveWebUrl()
{
if (OperatingSystem.IsAndroid())
{
return new Uri(_appSettings.WebServer.ForEndUrl);
}
return _appSettings.WebServer.IsUsingStatic
? new Uri(_appSettings.WebServer.HostUrl)
: new Uri(_appSettings.WebServer.ForEndUrl);
}
}
+7 -3
View File
@@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
x:Class="Hua.Todo.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Icon="/icon.ico"
Width="450"
Height="640"
Title="待办事项"
@@ -15,7 +15,11 @@
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<WebView Name="MainWebView" />
<Grid>
<TextBlock Text="Loading..."
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Window>
@@ -1,15 +1,12 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using System;
namespace Hua.Todo.Avalonia.Views;
/// <summary>
/// 应用主窗口。
/// 负责承载 WebView(前端 UI并在导航完成后注入前端契约字段
/// 负责承载 <see cref="MainView"/>(前端 UI)。
/// </summary>
public partial class MainWindow : Window
{
@@ -23,7 +20,8 @@ public partial class MainWindow : Window
{
InitializeComponent();
_appSettings = new AppSettings();
_webServer = new EmbeddedWebServerService(_appSettings);
_webServer = new NoopEmbeddedWebServerService(_appSettings.WebServer.HostUrl);
Content = new MainView(_appSettings, _webServer);
}
/// <summary>
@@ -36,71 +34,6 @@ public partial class MainWindow : Window
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebView();
}
private void SetupWebView()
{
try
{
if (_appSettings.WebServer.IsUsingStatic)
{
MainWebView.Url = new Uri(_webServer.BaseUrl);
}
else
{
MainWebView.Url = new Uri(_appSettings.WebServer.ForEndUrl);
}
MainWebView.NavigationCompleted += async (s, e) =>
{
try
{
if (_webServer.IsRunning)
{
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.ExecuteScriptAsync(@"
window.mauiInterop = {
onHotKeyConfigUpdated: null,
openHotKeySettings: function(config) {
const event = new CustomEvent('openHotKeySettings', { detail: config });
window.dispatchEvent(event);
},
updateHotKeyConfig: function(modifiers, key, isEnabled) {
const event = new CustomEvent('updateHotKeyConfig', {
detail: { modifiers, key, isEnabled }
});
window.dispatchEvent(event);
}
};
window.addEventListener('hotKeyConfigChanged', function(e) {
if (window.mauiInterop.onHotKeyConfigUpdated) {
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
}
});
");
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}");
Content = new TextBlock
{
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
Content = new MainView(_appSettings, _webServer);
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"IsUsingStatic": true,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
"ForEndUrl": "http://localhost:5057"
},
"HotKey": {
"DefaultModifiers": "Alt",
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+44
View File
@@ -0,0 +1,44 @@
#define MyAppName "Hua.Todo.Avalonia"
#define MyAppVersion "1.2.7"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.Avalonia.exe"
[Setup]
; 1. 改用更快压缩(优先速度)
Compression=zip/1
; 注意: AppId 的值唯一标识此应用程序。不要在其他应用程序的安装程序中使用相同的 AppId 值。
; (若要生成新的 GUID,请在 IDE 中单击“工具”|“生成 GUID”。)
AppId={{A1B2C3D4-E5F6-4321-8765-FEDCBA987654}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
SetupIconFile=icon.ico
UninstallDisplayIcon={app}\icon.ico
SolidCompression=no
WizardStyle=modern
[Languages]
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "bin\Release\net10.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
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
@@ -0,0 +1,52 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 安全审计日志实体。
/// </summary>
public class AuditLogEntity
{
/// <summary>
/// 日志唯一标识符。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 事件发生的 UTC 时间。
/// </summary>
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 相关用户 ID(可选,若为登录失败等场景可能为空)。
/// </summary>
public Guid? UserId { get; set; }
/// <summary>
/// 相关用户名。
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// 事件类型(如 LoginSuccess, LoginFailed, SyncStart, PolicyChanged 等)。
/// </summary>
public string EventType { get; set; } = string.Empty;
/// <summary>
/// 事件描述或详细信息。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 操作来源 IP。
/// </summary>
public string? ClientIp { get; set; }
/// <summary>
/// 终端信息(User-Agent 等)。
/// </summary>
public string? UserAgent { get; set; }
/// <summary>
/// 是否成功。
/// </summary>
public bool IsSuccess { get; set; } = true;
}
@@ -29,4 +29,14 @@ public class SecurityPolicyEntity
/// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。
/// </summary>
public bool AllowSync { get; set; } = true;
/// <summary>
/// 二次认证(Step-up)状态保持时长(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; } = 30;
/// <summary>
/// 是否仅限受信任终端执行高风险操作。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; } = false;
}
+10
View File
@@ -25,6 +25,16 @@ public class UserEntity
/// </summary>
public string Role { get; set; } = "user";
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 最后更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 用户任务集合。
/// </summary>
-2
View File
@@ -4,8 +4,6 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
+26 -4
View File
@@ -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;
using Hua.Todo.Application.CloudSync.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(options =>
{
options.DocumentFilter<DynamicApiSwaggerDocumentFilter>();
});
builder.Services.AddCloudSyncServer();
builder.Services.AddApplicationServices("Data Source=Hua.Todo.db");
@@ -26,11 +30,24 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Apply database migrations
// Apply database migrations and seed default admin
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
var services = scope.ServiceProvider;
var dbContext = services.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
dbContext.Database.Migrate();
// Seed default admin from configuration
var config = services.GetRequiredService<IConfiguration>();
var adminConfig = config.GetSection("DefaultAdmin");
var adminUserName = adminConfig["UserName"];
var adminPassword = adminConfig["Password"];
if (!string.IsNullOrWhiteSpace(adminUserName) && !string.IsNullOrWhiteSpace(adminPassword))
{
var authService = services.GetRequiredService<CloudAuthService>();
await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None);
}
}
if (app.Environment.IsDevelopment())
@@ -40,9 +57,14 @@ if (app.Environment.IsDevelopment())
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCors("AllowAll");
app.UseAuthentication();
app.UseAuthorization();
// 简单的管理后台入口跳转
app.MapGet("/admin", () => Results.Redirect("/admin/index.html"));
app.MapCloudSyncEndpoints();
app.UseDynamicApi();
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"DefaultAdmin": {
"UserName": "admin",
"Password": "ChangeMe@123"
}
}
+390
View File
@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hua.Todo 管理后台</title>
<!-- Element Plus & Vue 3 via CDN -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
body { margin: 0; font-family: -apple-system, sans-serif; background: #f5f7fa; }
#app { height: 100vh; display: flex; flex-direction: column; }
.header { background: #409EFF; color: white; padding: 0 20px; height: 60px; line-height: 60px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.main-container { flex: 1; overflow: hidden; display: flex; }
.sidebar { width: 200px; background: #fff; border-right: 1px solid #e6e6e6; }
.content { flex: 1; padding: 20px; overflow-y: auto; }
.login-container { height: 100vh; display: flex; justify-content: center; align-items: center; background: #f5f7fa; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.tag-admin { background-color: #f56c6c !important; border-color: #f56c6c !important; color: white !important; }
.audit-failed { color: #f56c6c; }
.audit-success { color: #67c23a; }
</style>
</head>
<body>
<div id="app">
<!-- 登录界面 -->
<div v-if="!isLoggedIn" class="login-container">
<el-card style="width: 400px;">
<template #header>
<div style="text-align: center; font-weight: bold;">管理员登录</div>
</template>
<el-form label-position="top">
<el-form-item label="用户名">
<el-input v-model="loginForm.userName" prefix-icon="User"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" prefix-icon="Lock" show-password @keyup.enter="handleLogin"></el-input>
</el-form-item>
<el-button type="primary" style="width: 100%" @click="handleLogin" :loading="loggingIn">登录</el-button>
</el-form>
</el-card>
</div>
<!-- 主界面 -->
<template v-else>
<div class="header">
<div style="font-size: 1.2rem; font-weight: bold;">Hua.Todo 管理后台 <small style="font-size: 0.8rem; opacity: 0.8;">v1.2.0</small></div>
<div>
<span style="margin-right: 15px;">欢迎, {{ currentUser.userName }}</span>
<el-button type="info" size="small" @click="refreshAll">刷新</el-button>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
</div>
</div>
<div class="main-container">
<div class="sidebar">
<el-menu :default-active="activeTab" @select="handleTabSelect" style="border-right: none;">
<el-menu-item index="users">用户管理</el-menu-item>
<el-menu-item index="sessions">会话管理</el-menu-item>
<el-menu-item index="logs">审计日志</el-menu-item>
</el-menu>
</div>
<div class="content">
<!-- 用户管理 -->
<div v-if="activeTab === 'users'">
<el-card>
<template #header>
<div class="card-header">
<span>用户列表</span>
<el-button type="primary" @click="showCreateUserDialog = true">新增用户</el-button>
</div>
</template>
<el-table :data="users" v-loading="loadingUsers" style="width: 100%">
<el-table-column prop="userName" label="用户名" width="180"></el-table-column>
<el-table-column prop="role" label="角色" width="120">
<template #default="scope">
<el-tag :class="scope.row.role === 'admin' ? 'tag-admin' : ''">{{ scope.row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAtUtc" label="创建时间" width="200">
<template #default="scope">{{ formatDate(scope.row.createdAtUtc) }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" @click="handleResetPassword(scope.row)">重置密码</el-button>
<el-button size="small" type="danger" @click="handleDeleteUser(scope.row)" :disabled="scope.row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 会话管理 -->
<div v-if="activeTab === 'sessions'">
<el-card>
<template #header>
<div class="card-header">
<span>当前在线会话</span>
<el-button size="small" @click="fetchSessions">刷新</el-button>
</div>
</template>
<el-table :data="sessions" v-loading="loadingSessions" style="width: 100%">
<el-table-column prop="userName" label="用户名" width="150"></el-table-column>
<el-table-column prop="isSteppedUp" label="已二次认证" width="120">
<template #default="scope">
<el-tag :type="scope.row.isSteppedUp ? 'success' : 'info'">{{ scope.row.isSteppedUp ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="expiresAtUtc" label="过期时间" width="200">
<template #default="scope">{{ formatDate(scope.row.expiresAtUtc) }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" type="warning" @click="handleRevokeSession(scope.row)">强制下线</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 审计日志 -->
<div v-if="activeTab === 'logs'">
<el-card>
<template #header>
<div class="card-header">
<span>最近 100 条审计日志</span>
<el-button size="small" @click="fetchLogs">刷新</el-button>
</div>
</template>
<el-table :data="logs" v-loading="loadingLogs" stripe style="width: 100%; font-size: 0.85rem;">
<el-table-column prop="timestampUtc" label="时间" width="160">
<template #default="scope">{{ formatDate(scope.row.timestampUtc) }}</template>
</el-table-column>
<el-table-column prop="eventType" label="事件" width="140"></el-table-column>
<el-table-column prop="userName" label="用户" width="120"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column prop="isSuccess" label="结果" width="80">
<template #default="scope">
<span :class="scope.row.isSuccess ? 'audit-success' : 'audit-failed'">{{ scope.row.isSuccess ? '成功' : '失败' }}</span>
</template>
</el-table-column>
<el-table-column prop="clientIp" label="IP" width="120"></el-table-column>
</el-table>
</el-card>
</div>
</div>
</div>
</template>
<!-- 新增用户对话框 -->
<el-dialog v-model="showCreateUserDialog" title="新增用户" width="400px">
<el-form :model="newUser" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="newUser.userName"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="newUser.password" type="password" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="newUser.role" style="width: 100%">
<el-option label="普通用户" value="user"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateUserDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateUser" :loading="creatingUser">确定</el-button>
</template>
</el-dialog>
</div>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const activeTab = ref('users');
const isLoggedIn = ref(false);
const loggingIn = ref(false);
const loginForm = reactive({ userName: '', password: '' });
const currentUser = ref({ userName: '', role: '' });
const users = ref([]);
const sessions = ref([]);
const logs = ref([]);
const loadingUsers = ref(false);
const loadingSessions = ref(false);
const loadingLogs = ref(false);
const showCreateUserDialog = ref(false);
const creatingUser = ref(false);
const newUser = reactive({ userName: '', password: '', role: 'user' });
// Axios 拦截器:注入 Token
axios.interceptors.request.use(config => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Axios 拦截器:处理鉴权失败
axios.interceptors.response.use(
response => response,
error => {
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
handleLogout();
ElementPlus.ElMessage.error('会话已失效或权限不足,请重新登录');
}
return Promise.reject(error);
}
);
const checkAuth = () => {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (token && user) {
isLoggedIn.value = true;
currentUser.value = JSON.parse(user);
return true;
}
return false;
};
const handleLogin = async () => {
if (!loginForm.userName || !loginForm.password) {
return ElementPlus.ElMessage.warning('请输入用户名和密码');
}
loggingIn.value = true;
try {
const res = await axios.post('/auth/login', loginForm);
const data = res.data;
if (data.role !== 'admin') {
throw new Error('只有管理员可以访问此后台');
}
localStorage.setItem('admin_token', data.accessToken);
localStorage.setItem('admin_user', JSON.stringify({ userName: loginForm.userName, role: data.role }));
isLoggedIn.value = true;
currentUser.value = { userName: loginForm.userName, role: data.role };
loginForm.password = '';
ElementPlus.ElMessage.success('登录成功');
refreshAll();
} catch (e) {
ElementPlus.ElMessage.error(e.message || '登录失败,请检查凭据');
} finally {
loggingIn.value = false;
}
};
const handleLogout = () => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
isLoggedIn.value = false;
currentUser.value = { userName: '', role: '' };
};
const fetchUsers = async () => {
if (!isLoggedIn.value) return;
loadingUsers.value = true;
try {
const res = await axios.get('/admin/users');
users.value = res.data;
} catch (e) {} finally {
loadingUsers.value = false;
}
};
const fetchSessions = async () => {
if (!isLoggedIn.value) return;
loadingSessions.value = true;
try {
const res = await axios.get('/admin/sessions');
sessions.value = res.data;
} catch (e) {} finally {
loadingSessions.value = false;
}
};
const fetchLogs = async () => {
if (!isLoggedIn.value) return;
loadingLogs.value = true;
try {
const res = await axios.get('/admin/audit-logs?count=100');
logs.value = res.data;
} catch (e) {} finally {
loadingLogs.value = false;
}
};
const handleTabSelect = (index) => {
activeTab.value = index;
if (index === 'users') fetchUsers();
if (index === 'sessions') fetchSessions();
if (index === 'logs') fetchLogs();
};
const handleCreateUser = async () => {
if (!newUser.userName || !newUser.password) {
return ElementPlus.ElMessage.warning('请填写完整信息');
}
creatingUser.value = true;
try {
await axios.post('/admin/users', newUser);
ElementPlus.ElMessage.success('创建用户成功');
showCreateUserDialog.value = false;
newUser.userName = '';
newUser.password = '';
fetchUsers();
} catch (e) {} finally {
creatingUser.value = false;
}
};
const handleResetPassword = async (user) => {
try {
const { value: newPass } = await ElementPlus.ElMessageBox.prompt(
`请输入用户 [${user.userName}] 的新密码`, '重置密码', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'password'
});
if (newPass) {
await axios.post(`/admin/users/${user.id}/reset-password`, { newPassword: newPass });
ElementPlus.ElMessage.success('重置成功');
}
} catch (e) {}
};
const handleDeleteUser = async (user) => {
try {
await ElementPlus.ElMessageBox.confirm(
`确定删除用户 [${user.userName}] 吗?此操作不可逆!`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await axios.delete(`/admin/users/${user.id}`);
ElementPlus.ElMessage.success('删除成功');
fetchUsers();
} catch (e) {}
};
const handleRevokeSession = async (session) => {
try {
await axios.delete(`/admin/sessions/${session.id}`);
ElementPlus.ElMessage.success('已强制下线');
fetchSessions();
} catch (e) {}
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString();
};
const refreshAll = () => {
fetchUsers();
fetchSessions();
fetchLogs();
};
onMounted(() => {
if (checkAuth()) {
refreshAll();
}
});
return {
activeTab, users, sessions, logs,
loadingUsers, loadingSessions, loadingLogs,
showCreateUserDialog, creatingUser, newUser,
isLoggedIn, loggingIn, loginForm, currentUser,
handleTabSelect, handleCreateUser, handleResetPassword, handleDeleteUser, handleRevokeSession,
handleLogin, handleLogout,
formatDate, fetchSessions, fetchLogs, refreshAll
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>
+50 -22
View File
@@ -1,8 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<!-- 在 Windows 平台构建时,追加 Windows 目标框架,同时保留 Android,避免覆盖。 -->
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
@@ -12,6 +14,7 @@
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<AndroidApplication>true</AndroidApplication>
<RootNamespace>Hua.Todo.Maui</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
@@ -26,18 +29,13 @@
<ApplicationTitle>代办</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<ApplicationId>com.hua.todo</ApplicationId>
<!-- Versions -->
<Version>1.1.9</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- Unified in Directory.Build.props -->
<!-- Assembly Info -->
<AssemblyTitle>待办</AssemblyTitle>
<AssemblyProduct>待办</AssemblyProduct>
<AssemblyCompany>Hua.Todo</AssemblyCompany>
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
<!-- Unified in Directory.Build.props -->
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
@@ -52,6 +50,11 @@
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<ForceWebBuild>false</ForceWebBuild>
<ForceFullAndroidDeploy>false</ForceFullAndroidDeploy>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
@@ -64,13 +67,16 @@
<Optimize>False</Optimize>
<EnableMauiImageProcessing>false</EnableMauiImageProcessing>
<DisableResizetizer>true</DisableResizetizer>
<!-- Android 调试默认跳过前端构建,减少每次 F5 等待时间。 -->
<SkipWebBuild>true</SkipWebBuild>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
<AndroidUseSharedRuntime>True</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>True</AndroidEnableFastDeployment>
<AndroidFastDeploymentType>Assemblies</AndroidFastDeploymentType>
<EmbedAssembliesIntoApk>False</EmbedAssembliesIntoApk>
<AndroidSupportedAbis>x86_64</AndroidSupportedAbis>
<!-- .NET 10 Preview 环境下,Shared Runtime 和 Fast Deployment 往往会导致 AndroidX 依赖解析异常 (NoClassDefFoundError)。
为了稳定性,默认启用完整部署模式 (EmbedAssembliesIntoApk=True)。 -->
<AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>False</AndroidEnableFastDeployment>
<EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
<RuntimeIdentifiers>android-x64</RuntimeIdentifiers>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
@@ -91,7 +97,7 @@
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#F8FAFC" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
@@ -120,6 +126,7 @@
<ItemGroup>
<EmbeddedResource Include="icon.ico" />
<EmbeddedResource Include="Platforms\Android\Resources\drawable\icon.ico" />
</ItemGroup>
<ItemGroup>
@@ -127,17 +134,21 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And '$(Configuration)' == 'Debug'">
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
</ItemGroup>
@@ -152,7 +163,7 @@
</ItemGroup>
<!-- Build web assets into MAUI `wwwroot` so both Windows publish and Android packaging can reuse the same folder. -->
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(MSBuildProjectDirectory)\wwwroot\index.html'))">
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="Exists('$(TodoWebDir)') And (!Exists('$(MSBuildProjectDirectory)\wwwroot\index.html') Or '$(SkipWebBuild)' != 'true' Or '$(ForceWebBuild)' == 'true')">
<Exec Command="npm ci" WorkingDirectory="$(TodoWebDir)" Condition="Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm install" WorkingDirectory="$(TodoWebDir)" Condition="!Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm run build:maui" WorkingDirectory="$(TodoWebDir)" />
@@ -170,21 +181,33 @@
<ItemGroup>
<MauiAsset Include="@(_TodoWebDistFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<LogicalName>$([System.String]::Copy('wwwroot/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(MSBuildProjectDirectory)\wwwroot\index.html')">
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="BeforeBuild;ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android'">
<ItemGroup>
<_MauiWwwrootFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" />
<!-- 确保包含所有 wwwroot 下的文件,包括子目录。 -->
<_MauiWwwrootFiles Include="wwwroot\**\*" />
</ItemGroup>
<ItemGroup>
<!-- 显式设置 Link 和 LogicalName。
在 Android 上,LogicalName 决定了 AssetManager.Open() 的路径。 -->
<MauiAsset Include="@(_MauiWwwrootFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>wwwroot\%(RecursiveDir)%(Filename)%(Extension)</Link>
<LogicalName>$([System.String]::Copy('wwwroot/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))</LogicalName>
</MauiAsset>
<!-- 同时尝试在根目录也放一份 index.html,增加容错。 -->
<MauiAsset Include="wwwroot\index.html" Condition="Exists('wwwroot\index.html')">
<Link>index.html</Link>
<LogicalName>index.html</LogicalName>
</MauiAsset>
</ItemGroup>
<Message Importance="high" Text="[Build] Included $(_MauiWwwrootFiles->Count()) assets from wwwroot into MauiAsset (with wwwroot/ prefix and root fallback)" />
</Target>
</Project>
@@ -200,3 +223,8 @@
+17 -3
View File
@@ -13,13 +13,14 @@ namespace Hua.Todo.Maui;
/// <summary>
/// MAUI 程序启动类
/// </summary>
public static class MauiProgram
public static partial class MauiProgram
{
/// <summary>
/// 创建并配置 MAUI 应用程序
/// </summary>
public static MauiApp CreateMauiApp()
{
ConfigurePlatformWebViewContainer();
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
@@ -71,7 +72,17 @@ public static class MauiProgram
// 注册嵌入式 Web 服务器(平台相关)
#if WINDOWS
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
// Windows 平台下嵌入式 WebServer 的启用策略:
// - 静态托管模式(IsUsingStatic=true):由 MAUI 内置 WebServer 提供 wwwroot 与本地 API。
// - 开发三件套模式(IsUsingStatic=false,前端走 Vite):API 由独立 Host(5173) 提供,避免在 MAUI 内启动 WebServer 导致注入覆盖前端代理配置。
if (appSettings.WebServer.IsUsingStatic)
{
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
}
else
{
builder.Services.AddSingleton<IEmbeddedWebServerService, NoopEmbeddedWebServerService>();
}
#elif ANDROID
builder.Services.AddSingleton<IEmbeddedWebServerService, MobileEmbeddedWebServerService>();
#else
@@ -89,8 +100,9 @@ public static class MauiProgram
{
try
{
InitializeDatabase(app.Services, connectionString);
// 优先启动内嵌 WebServer,确保 WebView 加载时不再 ERR_CONNECTION_REFUSED。
await StartWebServer(app.Services);
InitializeDatabase(app.Services, connectionString);
}
catch (Exception ex)
{
@@ -101,6 +113,8 @@ public static class MauiProgram
return app;
}
static partial void ConfigurePlatformWebViewContainer();
/// <summary>
/// 从 appsettings.json 加载配置
/// </summary>
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@drawable/appicon" android:roundIcon="@drawable/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.hua.todo">
<application android:allowBackup="true" android:icon="@drawable/appicon" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config" android:label="To&amp;Do">
<provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,6 +1,7 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.View;
namespace Hua.Todo.Maui;
@@ -18,8 +19,60 @@ public class MainActivity : MauiAppCompatActivity
{
base.OnCreate(savedInstanceState);
ConfigureSystemBars();
#if DEBUG
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
#endif
}
/// <summary>
/// Activity 恢复时回调。
/// 某些设备/启动链路会在恢复阶段重置系统栏样式,这里再次应用。
/// </summary>
protected override void OnResume()
{
base.OnResume();
ConfigureSystemBars();
}
/// <summary>
/// 窗口焦点变化回调。
/// 焦点切换后部分系统会重新应用主题色,需再次修正。
/// </summary>
/// <param name="hasFocus">是否获得焦点。</param>
public override void OnWindowFocusChanged(bool hasFocus)
{
base.OnWindowFocusChanged(hasFocus);
if (hasFocus)
{
ConfigureSystemBars();
}
}
/// <summary>
/// 配置 Android 系统栏样式,避免默认紫色状态栏与页面视觉割裂。
/// </summary>
private void ConfigureSystemBars()
{
if (Window is null)
{
return;
}
// 使用接近页面背景的浅色系统栏,弱化顶部挖孔区域的突兀感。
var systemBarColor = global::Android.Graphics.Color.ParseColor("#F8FAFC");
Window.SetStatusBarColor(systemBarColor);
Window.SetNavigationBarColor(systemBarColor);
// 保持内容在系统栏下方,避免顶部控件贴到状态栏区域。
WindowCompat.SetDecorFitsSystemWindows(Window, true);
var insetsController = WindowCompat.GetInsetsController(Window, Window.DecorView);
if (insetsController is not null)
{
insetsController.AppearanceLightStatusBars = true;
insetsController.AppearanceLightNavigationBars = true;
}
}
}
@@ -55,7 +55,9 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
if (_listener != null) return Task.CompletedTask;
_cts = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Loopback, _appSettings.WebServer.Port);
// 使用 IPAddress.Any 而非 Loopback,避免 localhost 解析到 IPv6 [::1] 时连接被拒绝。
// 同时确保在 Android 模拟器/设备上 localhost 能正确连通。
_listener = new TcpListener(IPAddress.Any, _appSettings.WebServer.Port);
_listener.Start();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
@@ -257,12 +259,42 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
return;
}
Console.WriteLine($"[WebServer] Serving static: {path} -> {assetPath}");
if (!TryOpenAsset(assetPath, out var assetStream))
{
// 如果 wwwroot/path 找不到,尝试直接加载 path(应对路径扁平化)
var fallbackAssetPath = path.TrimStart('/');
if (!string.IsNullOrEmpty(fallbackAssetPath) && !fallbackAssetPath.StartsWith("wwwroot", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"[WebServer] {assetPath} not found, trying fallback path: {fallbackAssetPath}");
if (TryOpenAsset(fallbackAssetPath, out assetStream))
{
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, GetContentType(path), token);
}
return;
}
}
if (!Path.HasExtension(path))
{
assetPath = "wwwroot/index.html";
if (TryOpenAsset(assetPath, out assetStream))
var fallbackHtmlPath = "wwwroot/index.html";
Console.WriteLine($"[WebServer] {assetPath} not found, trying SPA fallback: {fallbackHtmlPath}");
if (TryOpenAsset(fallbackHtmlPath, out assetStream))
{
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, "text/html; charset=utf-8", token);
}
return;
}
// 再次尝试不带 wwwroot 的 index.html
fallbackHtmlPath = "index.html";
Console.WriteLine($"[WebServer] {assetPath} not found, trying SPA fallback: {fallbackHtmlPath}");
if (TryOpenAsset(fallbackHtmlPath, out assetStream))
{
await using (assetStream)
{
@@ -272,6 +304,7 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
}
}
Console.WriteLine($"[WebServer] 404 Not Found: {assetPath}");
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
return;
}
@@ -286,11 +319,43 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
{
try
{
stream = global::Android.App.Application.Context.Assets.Open(assetPath);
// Android AssetManager.Open() 路径不能以 / 开头,且必须使用正斜杠
var normalizedPath = assetPath.TrimStart('/').Replace('\\', '/');
stream = global::Android.App.Application.Context.Assets.Open(normalizedPath);
return true;
}
catch
catch (Exception ex)
{
Console.WriteLine($"[WebServer] Failed to open asset '{assetPath}': {ex.Message}");
// 只在 wwwroot/index.html 失败时尝试列出文件,帮助排查打包结构
if (assetPath.EndsWith("index.html", StringComparison.OrdinalIgnoreCase))
{
try
{
// 列出 root assets 和 wwwroot 目录,帮助排查打包结构
var rootAssets = global::Android.App.Application.Context.Assets.List("");
if (rootAssets != null)
{
Console.WriteLine($"[WebServer] Root Assets: {string.Join(", ", rootAssets)}");
}
var assets = global::Android.App.Application.Context.Assets.List("wwwroot");
if (assets != null && assets.Length > 0)
{
Console.WriteLine($"[WebServer] Assets in 'wwwroot/': {string.Join(", ", assets)}");
}
else
{
Console.WriteLine("[WebServer] 'wwwroot/' directory is empty or not found in assets.");
}
}
catch (Exception listEx)
{
Console.WriteLine($"[WebServer] Failed to list assets: {listEx.Message}");
}
}
stream = Stream.Null;
return false;
}
@@ -423,6 +488,8 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
var requestLine = await reader.ReadLineAsync(token);
if (string.IsNullOrWhiteSpace(requestLine)) throw new InvalidOperationException("empty request line");
Console.WriteLine($"[WebServer] Raw Request Line: {requestLine}");
var parts = requestLine.Split(' ');
if (parts.Length < 2) throw new InvalidOperationException("invalid request line");
@@ -430,11 +497,19 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
var target = parts[1].Trim();
string path;
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri) &&
(absoluteUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
absoluteUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)))
if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
path = absoluteUri.AbsolutePath;
// WebView 有时会发送绝对 URL(例如 GET http://localhost:5057/ HTTP/1.1
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri))
{
path = absoluteUri.AbsolutePath;
Console.WriteLine($"[WebServer] Parsed path from absolute URI: {path}");
}
else
{
path = target;
}
}
else if (target.StartsWith("//", StringComparison.Ordinal) &&
Uri.TryCreate("http:" + target, UriKind.Absolute, out var authorityUri))
@@ -451,6 +526,8 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
if (string.IsNullOrEmpty(path)) path = "/";
if (!path.StartsWith("/", StringComparison.Ordinal)) path = "/" + path;
Console.WriteLine($"[WebServer] Final Resolved Path: {path}");
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? line;
while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(token)))
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>
<color name="colorPrimary">#F8FAFC</color>
<color name="colorPrimaryDark">#E2E8F0</color>
<color name="colorAccent">#2563EB</color>
</resources>
@@ -1,4 +1,6 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Handlers;
namespace Hua.Todo.Maui.Views
{
@@ -30,5 +32,67 @@ namespace Hua.Todo.Maui.Views
var windowService = new Platforms.Windows.WindowsWindowService();
windowService.MinimizeWindow(window);
}
/// <summary>
/// 配置 WebView 滚动条行为。
/// </summary>
partial void ConfigureWebViewScrollBars()
{
// 使用 JavaScript 注入的方式禁用滚动条
MainWebView.EvaluateJavaScriptAsync(@"
// 禁用页面垂直滚动条
document.body.style.overflowY = 'hidden';
// 禁用整个文档的滚动
document.documentElement.style.overflowY = 'hidden';
");
}
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 RuntimeEvergreen),安装完成后重新打开应用。",
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 };
}
}
}
@@ -0,0 +1,99 @@
using System.Reflection;
namespace Hua.Todo.Maui.Platforms.Windows;
internal static class WebView2RuntimeDetector
{
/// <summary>
/// 判断当前 Windows 系统是否可用 WebView2 Runtime。
/// 优先通过已知的 Evergreen 安装目录探测(避免因托管程序集缺失/裁剪导致误判),
/// 其次再尝试通过 WebView2 SDK 的 <c>CoreWebView2Environment.GetAvailableBrowserVersionString</c> 获取版本。
/// </summary>
/// <param name="version">检测到的运行时版本(若可用)。</param>
/// <returns>若系统存在可用的 WebView2 Runtime,则返回 <c>true</c>;否则返回 <c>false</c>。</returns>
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;
}
}
+23 -2
View File
@@ -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))
AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN))
+1 -1
View File
@@ -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;
@@ -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<DynamicApiSwaggerDocumentFilter>();
});
#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))
+17
View File
@@ -12,6 +12,7 @@ namespace Hua.Todo.Maui.Views
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService? _webServer;
private bool _isWebViewContainerReady = true;
/// <summary>
/// 创建 <see cref="MainPage"/>。
@@ -24,6 +25,12 @@ namespace Hua.Todo.Maui.Views
_appSettings = appSettings;
_webServer = webServer;
PlatformPrepareWebViewContainer();
if (!_isWebViewContainerReady)
{
return;
}
SetupWebViewSource();
SetupWebViewCommunication();
SetupKeyboardHandler();
@@ -112,9 +119,17 @@ namespace Hua.Todo.Maui.Views
}
});
");
// 配置 WebView 滚动条行为(平台特定实现)
ConfigureWebViewScrollBars();
};
}
/// <summary>
/// 配置 WebView 滚动条行为(平台特定实现)。
/// </summary>
partial void ConfigureWebViewScrollBars();
/// <summary>
/// 规格化 URL(针对 Android 模拟器处理 localhost)。
/// Android 模拟器中 localhost 指向模拟器自身,需要替换为 10.0.2.2 才能访问宿主机服务。
@@ -141,10 +156,12 @@ namespace Hua.Todo.Maui.Views
/// 平台特定的键盘处理器初始化。
/// </summary>
partial void PlatformSetupKeyboardHandler();
/// <summary>
/// 平台特定的 Esc 键处理逻辑。
/// </summary>
/// <param name="window">当前窗口。</param>
partial void PlatformOnEscKeyPressed(Window window);
partial void PlatformPrepareWebViewContainer();
}
}
+2 -2
View File
@@ -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": {
},
+10 -3
View File
@@ -1,5 +1,5 @@
#define MyAppName "Hua.Todo"
#define MyAppVersion "1.1.8"
#define MyAppVersion "1.2.7"
#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
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "Hua.Todo-web",
"name": "Hua.Todo-Web",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Hua.Todo-web",
"name": "Hua.Todo-Web",
"version": "0.0.0",
"license": "AGPL-3.0",
"dependencies": {
"axios": "^1.13.6",
"pinia": "^3.0.4",

Some files were not shown because too many files have changed in this diff Show More