Compare commits

..

10 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
ShaoHua 7a4c516a20 feat: 引入 CloudSync 核心能力并新增 Avalonia 桌面端与发布脚本
- 后端:新增 CloudSync 认证/权限/端点/服务与 DTO
- 数据:新增用户/会话/安全策略实体与 EF Core migrations
- 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot
- 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键
- 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置
- 文档:同步更新 README/docs 与协作规则
2026-04-07 03:34:34 +08:00
ShaoHua 18d37fdd24 doc:输出1.2.0版本需求文档 2026-04-07 00:36:39 +08:00
ShaoHua 141b3112a4 doc:整理文档 2026-04-06 23:25:57 +08:00
ShaoHua d00a907da0 refactor:规范代码格式和注释 2026-04-06 22:59:16 +08:00
175 changed files with 12016 additions and 531 deletions
+8
View File
@@ -366,3 +366,11 @@ FodyWeavers.xsd
/Hua.Todo/Output
/src/Hua.Todo.Maui/Output
/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
+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>
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- 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>
+4 -5
View File
@@ -6,13 +6,12 @@
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj">
<Build Solution="Debug|*" Project="false" />
</Project>
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj" />
<Project Path="src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj" />
<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>
</Solution>
+67 -121
View File
@@ -1,43 +1,16 @@
# Hua.Todo 跨平台代办管理应用
# Hua.Todo 跨平台代办管理应用 v1.2.8
一个基于 MAUI + WebView 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**后端通过 RESTful API 进行数据交互
### 技术特性
- **现代化架构**MAUI + WebView + C# 后端 + Vue.js 前端
- **分层设计**:Core(核心层)+ API(后端)+ Web(前端)
- **响应式界面**:Vue.js 3 实现的现代化用户界面
- **统一 API 设计**RESTful API 风格,支持跨域请求
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **UI 框架**MAUI (Multi-platform App UI)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript
- **框架**Vue.js 3
- **构建工具**Vite
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux
- **任务管理**:支持创建、编辑、删除、完成状态切换、子任务管理。
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略(SecurityPolicy)配置。
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感。
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析。
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试。
## 📦 安装与使用
@@ -59,12 +32,12 @@ cd Hua.Todo
#### 2. 启动后端 API
```bash
cd src/Hua.Todo.Api
cd src/Hua.Todo.Host
dotnet restore
dotnet ef database update
dotnet run
```
API 将在 `http://localhost:5173` 启动
开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`
#### 3. 启动前端 Web
```bash
@@ -72,7 +45,28 @@ cd src/Hua.Todo.Web
npm install
npm run dev
```
前端将在 `http://localhost:5173` 启动
前端将在 `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` 默认使用 `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` 提供静态文件)
### Linux 交付产物(v1.2.0
- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux`
- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/`
### 使用说明
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
@@ -85,104 +79,56 @@ npm run dev
### 项目结构
```
Hua.Todo/
├── pack/ # 打包与交付产物(Linux/安装包等)
├── docs/ # 文档目录
│ ├── 产品需求文档.md
── 产品需求文档-1.1.0.md
│ ├── 技术设计文档.md
│ └── 代码规范文档.md
│ ├── manual/ # 用户/开发者手册
── project/ # 项目进度/需求文档
├── src/ # 源代码目录
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ ├── Entities/ # 实体类
├── Task.cs
│ │ └── TaskPriority.cs
│ └── Interfaces/ # 接口定义
│ ├── ITaskRepository.cs
│ │ └── ITaskService.cs
│ ├── Hua.Todo.Api/ # 后端 API 项目
│ │ ├── Controllers/ # API 控制器
│ │ │ └── TasksController.cs
│ │ ├── Services/ # 业务服务
│ │ │ └── TaskService.cs
│ │ ├── Repositories/ # 数据访问层
│ │ │ └── TaskRepository.cs
│ │ ├── Data/ # 数据库上下文
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Models/ # 数据模型
│ │ │ └── TaskModels.cs
│ │ ├── Program.cs # API 入口
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ ├── src/
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts
│ │ │ │ └── tasks.ts
│ │ │ ├── components/ # Vue 组件
│ │ │ │ ├── TaskList.vue
│ │ │ │ └── TaskItem.vue
│ │ │ ├── types/ # TypeScript 类型定义
│ │ │ │ └── task.ts
│ │ │ ├── App.vue # 根组件
│ │ │ └── main.ts # 应用入口
│ │ ├── package.json # 依赖配置
│ │ ├── vite.config.ts # Vite 配置
│ │ └── tsconfig.json # TypeScript 配置
│ ├── Hua.Todo.Core/ # 领域实体与基础接口
│ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现
├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux)
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档
```
### 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` - 获取子任务列表
## 🎯 核心模块说明
## 🤝 交流与贡献
### Hua.Todo.Core
核心业务逻辑层,定义领域模型和业务规则,提供核心业务接口。
- **QQ 交流群**2167048911 (Hua.Todo 交流群)
- **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md)
### Hua.Todo.Api
后端 API 项目,提供 RESTful API 接口,处理业务逻辑,管理数据访问和持久化。
## 📄 开源协议
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript,提供用户界面,通过 HTTP API 与后端通信。
本项目采用 **AGPL-3.0** 许可证。详细内容请参阅 [LICENSE](LICENSE) 文件。
## 🔄 版本更新
## 📚 更多文档
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
### 用户与开发者手册
- [技术栈与模块说明](docs/manual/技术栈与模块.md)
- [版本更新历史](docs/manual/版本记录.md)
- [技术设计文档](docs/manual/技术设计文档.md)
- [代码规范文档](docs/manual/代码规范文档.md)
- [其他信息 (贡献、许可证、联系方式)](docs/manual/其他信息.md)
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
## 🤝 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](LICENSE) (英文) 或 [LICENSE.zh-CN](LICENSE.zh-CN) (中文) 文件了解详情
## 📞 联系方式
- 项目作者:ShaoHua
- 项目地址:https://git.we965.cn/Tools/Hua.Todo
### 项目进度与需求
- [产品需求文档](docs/project/产品需求文档.md)
- [Android 离线排查计划](docs/project/Android_NotFound_排查计划.md)
- [实现对比文档](docs/project/实现对比文档.md)
---
**Hua.Todo** - 跨平台任务管理,让效率无处不在!
@@ -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();
+19
View File
@@ -0,0 +1,19 @@
# 其他信息
## 🤝 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](LICENSE) (英文) 或 [LICENSE.zh-CN](LICENSE.zh-CN) (中文) 文件了解详情
## 📞 联系方式
- 项目作者:ShaoHua
- 项目地址:https://git.we965.cn/Tools/Hua.Todo
- QQ 交流群:2167048911 (Hua.Todo 交流群)
+46
View File
@@ -0,0 +1,46 @@
# 技术栈与模块说明
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 13 (基于 .NET 10)
- **框架**.NET 10 (Preview/Early Adopter)
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API (支持动态 API 生成)
- **数据访问**Entity Framework Core 10.0
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript 5+
- **框架**Vue.js 3
- **构建工具**Vite 5+
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
## 🎯 核心模块说明
### Hua.Todo.Core
领域实体层,定义核心实体(TaskEntity)、安全策略实体(SecurityPolicyEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
### Hua.Todo.Application
应用层实现,包含:
- **业务逻辑**TaskService 实现。
- **动态 API**:基于 Middleware 的动态 API 生成逻辑。
- **云同步 (CloudSync)**:包含 Auth 验证、同步服务、安全策略管理等。
- **数据访问**EF Core 数据库上下文(TodoDbContext)与迁移。
### Hua.Todo.Host
后端 API 宿主,作为独立 Server 运行时提供运行环境和配置。
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
### Hua.Todo.Maui
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
### Hua.Todo.Avalonia
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
@@ -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
@@ -30,10 +30,15 @@
```
Hua.Todo/
├── docs/ # 文档目录
│ ├── PRD.md # 产品需求文档
│ ├── PRD-1.1.0.md # v1.1.0 产品需求文档
│ ├── TechnicalDesign.md # 技术设计文档(本文件)
└── CodeStandards.md # 代码规范文档
│ ├── manual/ # 用户/开发者手册
│ ├── 技术栈与模块.md
│ ├── 版本记录.md
│ ├── 技术设计文档.md(本文件)
│ │ ├── 代码规范文档.md
│ │ └── 部署文档.md
│ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md
│ └── ...
├── src/ # 源代码目录
│ ├── Hua.Todo.Maui/ # MAUI 主项目(跨平台入口)
@@ -69,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
@@ -85,6 +113,8 @@ Hua.Todo/
│ │ │ └── SyncService.cs
│ │ ├── Data/ # 数据访问层
│ │ │ ├── TodoDbContext.cs
│ │ │ ├── Converters/ # 类型转换器
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
@@ -95,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/ # 实体类
@@ -112,7 +149,7 @@ Hua.Todo/
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ │ └── index.html
│ │ │ └── favicon.svg # 网站图标
│ │ ├── src/ # 源代码
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts # HTTP 客户端配置
@@ -169,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)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
@@ -200,7 +259,7 @@ Hua.Todo/
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.4 前端 Web 项目 (Hua.Todo.Web)
### 4.5 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
@@ -216,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. 数据库设计
@@ -306,6 +368,7 @@ CREATE TABLE Settings (
- **Windows**: WebView2 运行时要求
- **macOS**: 代码签名和公证
- **移动端**: 应用商店发布配置
- **Linux**: .NET MAUI 无官方 Linux 目标,需要引入独立桌面宿主(例如基于 WebKitGTK 的方案)并处理运行时依赖与打包格式
## 9. 性能优化
+65
View File
@@ -0,0 +1,65 @@
# 版本更新历史
## 🔄 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### 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 为只读展示);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。
- **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` 等模块的路径与职责描述。
- **端口配置校准**:修正文档中关于前端与后端 API 的端口说明(5173/5174)。
- **PRD 校准**:移除 v1.2.0 PRD 中“本地迭代不支持”表述与“数据迁移(导入/导出)”小节。
- **PRD 校准**:移除 v1.2.0 PRD 中“云同步”需求。
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持 (Windows, macOS, Android, iOS)
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
- 实现子任务支持
### v1.2.0 规划内容 (即将推出)
- **Linux 官方支持**:正式适配 Linux 平台。
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpakmanifest/desktop entry/AppStream)基础结构。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
+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,49 @@
# Hua.Todo v1.2.0 任务拆分总览
本目录用于把 [产品需求文档-1.2.0.md](file:///d:/Proj/6.Hua.Todo/docs/project/产品需求文档-1.2.0.md) 拆解为可落地、可并行推进的子任务。各任务文件之间尽量解耦;存在明确依赖时会在任务内标注。
## 目标(来自 PRD
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有“本地 API ↔ 前端”的交互协议
- 任务检索(Search):主界面顶部新增搜索框,按任务标题模糊匹配
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的“可控落盘”
## 当前实现基线(用于任务定位)
- MAUI 启动与内嵌 WebServer`src/Hua.Todo.Maui`
- 前端(Vue)与 API client`src/Hua.Todo.Web`
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__``window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
## 任务流(并行建议)
- **Linux 入口线**`01-*` + `02-*`
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1``pack/linux/`
- **Search 线**`03-*`
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
- 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
- **云同步线**`04-*` + `05-*` + `06-*`
- 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离)
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务)
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与“内存模式”)
- **文档与验收线**`07-*`
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
## 待验证表
| 子任务 | 实现状态 | 验证状态 | 备注 |
|---|---|---|---|
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增“云同步设置”弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 |
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
| 07 - 文档与验收 | 未标注 | 待验证 | |
## 交付判定(v1.2.0 Done Definition
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
- Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发“是否允许落盘”;客户端在禁止落盘时不产生本地持久化
@@ -0,0 +1,93 @@
# 01 - LinuxAvalonia 入口 + WebView 承载 Vue
## 目标
- 新增一个 **Linux 桌面端入口**Avalonia)作为 Hua.Todo 的 Linux 宿主
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
- 复用既有“前端 ↔ 本地 API”的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
## 范围
- 新增/调整项目结构以容纳 Avalonia 入口(建议新项目:`src/Hua.Todo.Avalonia``src/Hua.Todo.Desktop.Avalonia`
- 在 Avalonia 中接入可在 Linux 运行的 WebView 控件(需要先调研与选型)
- 确保能正确加载:
- 内嵌 WebServer 的 `BaseUrl`
- 以及前端静态资源(开发态/生产态策略需明确)
## 依赖
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`
## 关键决策点(必须在实现前落定)
### 1) Avalonia/Linux 可用的 WebView 方案选型
候选方向(示例,最终以调研结果为准):
- WebKitGTK 系(Linux 依赖较重,但适配面广)
- Chromium 系(体积/依赖/许可成本需评估)
选型必须满足的最低能力:
- 基础导航与本地资源加载
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证“HTTP API”路径通)
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
### 2) 资源加载策略(与 MAUI 对齐)
需要明确并实现两种模式:
- 开发态:Avalonia WebView 指向前端 dev server(例如 `http://localhost:5173`)或指向本地 WebServer
- 生产态:加载随应用交付的静态资源(`wwwroot`)并确保 SPA fallback 生效(访问非 `/assets/*` 的路由能回到 `index.html`
## 实现落地(已完成)
### 1) 项目结构
- 新增 Linux 桌面端入口项目:`src/Hua.Todo.Avalonia`
- 入口类型:Avalonia Desktop AppClassicDesktopLifetime
### 2) WebView 方案选型(已落定)
- 采用:`WebView.Avalonia` + `WebView.Avalonia.Desktop`
- Windows:依赖 WebView2 Runtime
- Linux:依赖 GTK + WebKitGTK(运行环境缺失时 WebView 会初始化失败)
### 3) 资源加载策略(与 MAUI 对齐)
- 生产态(默认):内嵌 WebServer 托管 `wwwroot` 静态资源,WebView 指向 `HostUrl`
- 开发态:将 `IsUsingStatic=false`WebView 指向 `ForEndUrl`(例如 Vite dev server
### 4) 前端契约对齐(与 MAUI 一致)
- 在 WebView 导航完成后注入:
- `window.__API_BASE_URL__ = "${HostUrl}/api"`
- `window.mauiInterop`(事件名/字段名与现有前端保持一致)
### 5) 本地 API 与数据库初始化
- 内嵌 WebServer 使用 Kestrel 启动并注册动态 API 与 Controllers
- 启动时执行数据库迁移(Migrate),并设置 SQLite WAL 模式以降低锁冲突风险
- 默认 SQLite 路径使用用户目录(`LocalApplicationData/Hua.Todo/Hua.Todo.db`),避免安装目录无写权限导致启动失败
## 实施步骤(建议)
1. 新建 Avalonia 宿主项目
- 提供 App/Window/MainView 基础结构
2. 接入 WebView 控件并完成最小加载闭环
- 能显示 `index.html`,能打开前端路由
3. 对齐与 MAUI 端一致的“前端契约”
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
4. 复用本地 API(内嵌 WebServer
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
- 明确 Linux 端是否也走“内嵌 WebServer + WebView 指向 BaseUrl”的方式(推荐一致化,减少前端差异)
5. 输入法/字体/DPI 验证与可配置化
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
## 验收标准
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
- WebView 正常渲染前端主界面
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
- 页面路由/刷新不会出现 404SPA fallback 生效)
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
@@ -0,0 +1,41 @@
# 02 - Linux:打包、依赖与交付产物
## 目标
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到“用户机器无需手动补大量依赖即可运行”
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
## 范围
- 构建脚本与产物输出
- 运行时依赖打包策略(尤其是 WebView 相关)
- 基础安装/运行说明(README / docs 更新)
## 依赖
- 依赖 `01-*`Avalonia 入口与 WebView 方案已定、并能在开发机运行)
## 交付形式(PRD 约束)
- 优先提供一种“自包含”官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
- 同时保留 `.deb``.tar.gz` 作为补充分发形式(以脚本产出为准)
## 实施步骤(建议)
1. 确定 Linux 发行版基线与运行时依赖清单
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
2. 选择并落地一种自包含交付形式
- AppImage:偏“拎包即用”,对依赖捆绑要求高
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
3. 补充 `.deb``.tar.gz`
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
- `.tar.gz`:最通用;需要启动脚本与依赖说明
4. 增加启动前自检(可选但推荐)
- 发现关键依赖缺失时给出清晰错误提示与修复建议
## 验收标准
- 产物可在“干净环境”(尽量接近用户机)安装/运行
- 启动后 WebView 能渲染前端,且本地 API 可用
- 文档中给出的安装/运行步骤可复现,且与产物一致
@@ -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` 两个安装包。
@@ -0,0 +1,45 @@
# 03 - Search:任务标题关键词检索
## 目标
- 在主界面顶部增加搜索框
- 支持按任务标题进行模糊匹配(实时过滤)
## 范围
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
- 过滤逻辑:对“树状任务”给出明确策略
- 性能:任务量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
## 依赖
- 不依赖 Linux/Avalonia 或云同步,可独立并行完成
## 过滤策略(需在实现时确定,并写入实现说明)
树状任务(父子任务)常见两种策略,任选其一并保持一致:
1. **命中即显示(含上下文)**
- 任意节点标题命中则显示该节点
- 若子任务命中,可同时显示其祖先链(便于理解层级)
2. **扁平化命中**
- 只显示命中的任务(可能丢失层级语义)
建议优先采用“命中即显示(含上下文)”,用户可更快定位。
## 实施步骤(建议)
1. 确定搜索框放置位置(主界面顶部)
2. 增加 `searchQuery` 状态与派生 `filteredTasks`
3. 将过滤逻辑接入现有列表渲染
4. 补充交互细节
- 输入时实时过滤
- 清空按钮
- Esc 清空、保持输入焦点(可选)
## 验收标准
- 输入关键字后,任务列表实时过滤,仅展示命中结果(符合上述策略)
- 清空后恢复原列表
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
@@ -0,0 +1,147 @@
# 04 - 云同步(基础可用):服务端基础能力
## 目标(PRD 约束)
- 支持用户级任务数据同步/读取(用户隔离)
- 提供基于角色/权限的访问控制(RBAC)
- 对高风险操作支持二次认证(能力以服务端实现为准)
- 下发“可控落盘”配置(服务端配置驱动)
## 范围(建议最小闭环)
- 认证(登录/会话)
- 任务数据 API(按用户隔离)
- 配置下发 API(是否允许落盘、终端可信度/会话策略等)
- RBAC 最小落地(至少能区分“允许同步/禁止同步”或“只读/读写”)
- 二次认证最小落地(至少覆盖“启用/关闭同步、切换账号、调整落盘策略”等高风险操作的接口)
## 依赖
-`05-*`(客户端)可并行推进,但需要尽早冻结 API 契约(字段名/响应结构/错误码)
## 接口契约(需要在实现前先写清楚)
v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结字段名/错误码)。
### 通用约定
- Base URL:由客户端配置(例如 `https://{host}:{port}`
- 认证:`Authorization: Bearer {accessToken}`
- 错误响应(统一结构):
```json
{ "code": "ERROR_CODE", "message": "Human readable message." }
```
### 错误码
- `UNAUTHORIZED`:未登录或会话失效
- `FORBIDDEN`:权限不足(RBAC / 策略拒绝)
- `SECOND_FACTOR_REQUIRED`:需要二次认证(step-up
- `BAD_REQUEST`:请求参数不合法
- `NOT_FOUND`:资源不存在
### Auth
- 初始化管理员(仅当系统尚无云用户时允许一次):
- `POST /auth/bootstrap`
- Body`{ "userName": "admin", "password": "..." }`
- 200:成功;403:已初始化或不允许
- 登录:
- `POST /auth/login`
- Body`{ "userName": "admin", "password": "..." }`
- 200`{ accessToken, expiresAtUtc, userId, role, permissions[] }`
- 二次认证(step-up,v1.2.0 最小实现为“复用登录口令进行再认证”):
- `POST /auth/step-up`
- Header`Authorization: Bearer ...`
- Body`{ "password": "..." }`
- 200`{ stepUpExpiresAtUtc }`
### Tasks
- 获取当前用户任务全量:
- `GET /tasks`
- Header`Authorization: Bearer ...`
- 权限:`tasks:read`
- 200`CloudTaskItem[]`
### Sync
- 上传/同步(增删改后返回最新全量):
- `POST /sync`
- Header`Authorization: Bearer ...`
- 权限:`sync:write`
- 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`
- Body
```json
{
"upserts": [
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null },
{ "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null }
],
"deletes": [2, 3]
}
```
- 200
```json
{
"serverTimeUtc": "2026-04-06T17:39:30.0281279Z",
"tasks": [ /* CloudTaskItem[] */ ]
}
```
### Security Policy
- 获取安全策略(服务端下发):
- `GET /security/policy`
- Header`Authorization: Bearer ...`
- 权限:`policy:read`
- 200
```json
{
"allowPersist": true,
"allowSync": true,
"requireSecondFactorFor": ["sync:write", "policy:write"]
}
```
- 更新安全策略(高风险操作):
- `PUT /security/policy`
- Header`Authorization: Bearer ...`
- 权限:`policy:write`
- 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`
- Body`{ "allowPersist": false, "allowSync": false }`
## 关键实现点(建议)
详细实现方案请参考:[06.1-CloudSync-服务端安全设计方案.md](file:///d:/Proj/6.Hua.Todo/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md)
1. 用户隔离
- 服务端所有读写必须绑定当前登录用户上下文
2. 权限模型(RBAC
- 定义最小角色:例如 `user``admin`
- 定义最小权限:例如 `tasks:read``tasks:write``sync:enable`
3. 二次认证
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现“二次口令”)
- 能力不足时必须返回明确错误,使客户端可提示用户
4. 落盘策略下发
- 支持按用户或按会话/终端下发
- 默认策略建议为“允许落盘”,便于可用性;但需支持“禁止落盘”
## 验收标准
- 使用不同用户登录后获取任务,数据严格隔离
- 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别)
- 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为
## 当前实现说明(v1.2.0
- 数据隔离:`Tasks` 表新增 `UserId` 外键,云端 API 的所有读写按当前会话用户隔离;本地模式使用固定的 `local` 用户 ID(不影响既有 Dynamic API)。
- RBAC:内置 `admin / user / readonly / nosync` 角色与权限映射;并叠加 `SecurityPolicies.AllowSync` 作为“是否允许同步写入”的策略开关。
- 二次认证:通过 `POST /auth/step-up` 将会话提升到 step-up 状态;`POST /sync``PUT /security/policy` 会强制要求 step-up。
@@ -0,0 +1,48 @@
# 05 - 云同步(基础可用):客户端配置与同步工作流
## 目标(PRD 约束)
- 首次启用同步时,用户需要**手动填写服务端地址**(例如 `https://example.com`),并允许后续在设置中修改
- 客户端对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息
- 用户登录成功后,从服务端获取该用户任务并更新到前端展示
## 范围(建议最小闭环)
- “同步设置”入口与界面(服务端地址、登录、同步开关)
- 地址校验与风险提示
- 登录后拉取任务并刷新 UI
- 与本地数据的关系(v1.2.0 建议先做到“服务端为准/或本地为准”的单一策略,避免引入复杂冲突解决)
## 依赖
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取任务 + 获取安全策略)
-`03-*`Search)互不影响,可并行
## 关键交互与状态
需要定义并贯穿实现的状态机(至少包含):
- 未配置服务端地址
- 已配置未登录
- 已登录(可同步)
- 同步中/同步失败/同步成功(含失败原因)
## 实施步骤(建议)
1. 增加“同步设置”UI
- 放置在主界面可发现位置(例如顶部右侧设置按钮/侧边栏)
2. 服务端地址配置
- 基础校验:`https?://`、host 合法性、尾部 `/` 处理规则
- 保存时探测:尝试请求服务端健康检查或登录端点(并提示证书异常/不可达等)
3. 登录与凭据存储策略(与 `06-*` 协同)
- 在允许落盘场景下可持久化 token/会话
- 在禁止落盘场景下仅保留内存会话(退出即失效)
4. 登录后拉取任务
- 拉取成功后更新前端任务列表
- 拉取失败给出可理解提示,并允许重试
## 验收标准
- 首次启用同步必须先配置服务端地址;地址非法时不可保存并提示原因
- 保存地址时能提示“不可达/证书异常”等风险信息(至少能区分:成功、失败、存在风险但可继续)
- 登录后能从服务端拉取任务并展示在主界面
@@ -0,0 +1,57 @@
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与“可控落盘”
## 目标(PRD 约束)
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整“是否允许落盘”等)
- 客户端读取服务端安全配置,决定任务信息是否允许落盘
- 当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据
## 范围
- 客户端:落盘策略切换(持久化 ↔ 内存)、高风险操作二次确认/二次认证 UI
- 服务端:策略下发 + 二次认证挑战/校验接口(与 `04-*` 协作)
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约)
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
### 1) “落盘”定义与边界
需要明确“哪些数据算落盘”,并在禁止落盘时全部避免:
- 任务数据(列表/详情)
- 同步状态(上次同步时间、待同步队列等)
- 登录凭据(token/refresh token/会话标识)
### 2) 本地存储抽象
建议把前端(或宿主)本地存储封装为可替换实现:
- 允许落盘:使用现有 localStorage/SQLite 等
- 禁止落盘:使用内存实现(刷新/退出即清空)
要求:
- 切换策略时行为可预期(例如从允许 → 禁止:立即清空已落盘数据并提示用户)
- 不在日志或 UI 中暴露敏感信息
### 3) 二次认证(挑战-响应)
建议采用“服务端驱动”的挑战流程:
- 客户端发起高风险操作
- 服务端返回“需要二次认证”的响应(包含 challenge 信息)
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
- 客户端携带证明再次提交
若服务端暂不支持二次认证,也需有“能力探测/降级提示”,避免客户端卡死。
## 验收标准
- 服务端返回 `allowPersist=false` 时:
- 客户端不会把任务数据与凭据写入任何持久化介质
- 退出应用后重新进入,任务数据为空(需重新登录/拉取)
- 对指定高风险操作:
- 无二次认证证明时被拒绝,并提示需要二次认证
- 提供正确二次认证后操作成功
@@ -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)机制。
@@ -0,0 +1,39 @@
# 07 - 文档同步与验收清单(v1.2.0)
## 目标
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
- 为 Linux / Search / 云同步提供可复现的验收步骤
## 范围
- READMEFeatures、运行方式、API 说明(如涉及)
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
## 必做同步点(结合当前仓库现状)
- **API 端点与项目结构**:对齐实际实现(避免文档仍描述不存在的项目或旧端点)
- **Linux 入口说明**:新增 Avalonia 入口的构建/运行/打包方式
- **云同步说明**:服务端地址配置、登录、落盘策略与安全机制(RBAC/二次认证)的用户可理解描述
- **版本记录**:在 `docs/版本记录.md` 增加 v1.2.0 非琐碎变更条目
## 验收清单(建议逐项勾选)
### Linux
- [x] Linux 上可启动并打开主界面
- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [x] 前端能成功请求本地 `/api/*` 并加载任务列表
- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md
### Search
- [x] 主界面有搜索框
- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [x] 清空后恢复原列表
### 云同步(基础可用)
- [x] 可手动配置服务端地址,并有基础校验与风险提示
- [x] 登录后能拉取该用户任务并展示
- [x] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [x] 高风险操作触发二次认证(服务端支持时)
+84
View File
@@ -0,0 +1,84 @@
# Hua.Todo 产品需求文档 (PRD) v1.2.0
## 1. 项目概述
在 v1.1.0 版本成功实现 MAUI + WebView 跨平台架构的基础上,v1.2.0 版本将以“**MAUI 入口保持不动 + Linux 新增 Avalonia 入口**”的方式落地 Linux 支持(继续使用 WebView 承载 Vue 前端),并在此基础上探索 Avalonia 对其他终端的支持情况;若验证效果良好,后续版本将推进各终端入口统一切换到 Avalonia。
## 2. 核心目标
- **完善平台覆盖**:正式支持 Linux 平台。
- **增强任务组织**:引入搜索。
- **云同步(基础可用)**:支持用户级任务数据同步与“可控落盘”(在不信任终端场景下可禁止落盘)。
## 3. 功能需求
### 3.1 平台支持:Linux 官方支持
- **客户端框架迁移**:保持原 MAUI 项目不动,实现 Avalonia 对 Linux 端支持,并探索 Avalonia 对 Android 和其他平台的支持。
- **终端入口策略(v1.2.0**
- **保持 MAUI 入口不动**Windows/macOS/iOS/Android 继续沿用现有 MAUI 入口与 WebView 方案,保证迭代稳定性。
- **Linux 使用 Avalonia 作为入口**:新增 Avalonia 桌面端宿主作为 Linux 入口,继续用 WebView 承载同一套 Vue 前端,并复用既有“本地 API ↔ 前端”的交互协议。
- **评估全端切换可行性**:在完成 Linux 入口落地后,评估 Avalonia 在 Windows/macOS(以及后续 Android 等)的稳定性、WebView 兼容性与打包成本;若效果良好,后续版本将各终端入口统一切换到 Avalonia。
- **适配性**:确保 WebView 在 Linux 环境下的正常渲染与交互。
- **打包部署**:提供 Linux 平台的打包脚本(如 .deb 或 .tar.gz)。
### 3.2 任务检索 (Search)
- **关键词搜索**:在主界面顶部增加搜索框,支持按任务标题模糊匹配。
### 3.3 云同步(基础可用)
#### 3.3.1 服务端地址配置(用户手动指定)
- **用户手动指定服务端地址**:首次启用同步时,用户需要手动填写服务端地址(例如 `https://example.com`),并允许后续在设置中修改。
- **地址有效性提示**:客户端需对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息。
#### 3.3.2 权限与二次认证(RBAC + 二次认证)
- **RBAC 权限模型**:服务端必须提供基于角色/权限的访问控制(Role-Based Access Control),以支持后续按功能、数据域进行授权。
- **二次认证**:对高风险操作(例如启用/关闭同步、切换账号、调整“是否允许落盘”等安全相关配置)应支持二次认证机制(例如二次口令/一次性验证码等),具体实现以服务端能力为准。
#### 3.3.3 基于用户的数据隔离
- **用户级数据隔离**:服务端必须按用户隔离任务数据,任何同步、检索与配置读取都必须绑定当前登录用户上下文。
- **最小权限原则**:客户端仅请求完成任务同步所需的最小权限范围,避免默认授予不必要的数据访问能力。
#### 3.3.4 同步工作流(登录后获取任务 + 可控落盘)
- **登录后拉取任务**:用户登录成功后,客户端从服务端获取该用户的任务信息并更新到前端展示。
- **服务端配置驱动的落盘策略**:客户端需读取服务端针对该用户(或该会话/终端)的安全配置,决定任务信息是否允许落盘到本地存储。
- **不信任终端禁止落盘**:当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据。
## 4. 技术实现要点
### 4.1 Linux 适配
v1.1.0 起客户端使用 **MAUI WebView** 承载 Vue 前端;在 v1.2.0 中,保持现有 MAUI 入口不动,同时为 Linux 新增 **Avalonia 桌面端**入口,并继续以 WebView 承载同一套 Vue 前端。后续将基于 Linux 端落地经验评估 Avalonia 对 Windows/macOS(以及后续 Android 等)的覆盖与稳定性,满足条件后推进全端入口统一。
#### 4.1.1 Linux 解决方案(Avalonia 入口 + WebView 承载 Vue
- **入口与架构**Linux 端新增 Avalonia 桌面宿主作为应用入口;加载同一套 Vue 前端,并保持与现有 MAUI 端一致的“前端调用本地 API”协议与数据模型,确保业务逻辑与 UI 复用。
- **WebView 方案**:选用可在 Avalonia/Linux 运行的 WebView 控件承载前端(以 WebKitGTK/Chromium 系为候选实现),统一约束:支持基础导航/本地资源加载、JS ↔ Native 双向通信、DevTools(至少开发环境可用)。
- **发行版与依赖策略**:定义最低支持范围(例如 Ubuntu LTS 系列作为基线),并将 WebView 运行时依赖纳入打包交付(避免用户手动安装大量依赖导致不可用)。
- **输入法/字体/DPI**:提供默认 CJK 字体与渲染配置,验证 IME、缩放、Wayland/X11 关键组合;遇到环境差异时以“可配置 + 明确降级”保证可用性。
- **快捷键策略**:全局快捷键在 Linux 上采用“尽力而为”的可插拔实现;在 Wayland 等受限环境下自动降级为应用内快捷键,并在设置中提供可配置与提示。
- **交付形式**:优先提供一种“自包含”的交付产物(例如 AppImage/Flatpak 之一)作为官方安装方式,同时保留 `.deb/.tar.gz` 作为补充分发形式(以脚本产出为准)。
## 5. 版本规划
### 5.1 v1.2.0 目标
- [ ] Linux 平台完整支持与打包脚本。
- [ ] 搜索框(关键词检索)。
- [ ] 云同步(基础可用:用户手动配置服务端地址、RBAC + 二次认证、用户级数据隔离、服务端配置驱动的可控落盘)。
### 5.2 后续版本规划
- **v1.3.0**:数据导入导出(JSON)、统计图表(任务完成趋势、时间分配分析)。
- **v1.4.0**:高级过滤(优先级/时间段)与更丰富的视图(如看板/日历)。
## 6. 风险评估
- **Linux 碎片化**:不同发行版下 WebView 的依赖库可能不一致。
- **凭据与合规**:第三方服务鉴权方式差异大,且必须保证凭据安全存储与最小化权限。
- **终端可信度与数据落盘**:若需支持“不信任终端不落盘”,需要明确“可信度判定与配置下发”的服务端策略,并保证客户端在无落盘场景下的可用性与体验。
+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
+212
View File
@@ -0,0 +1,212 @@
<#
Hua.Todo Linux 发布脚本(产出 .tar.gz
目标:
- 为 Linux 提供可分发的目录产物(dotnet publish 输出)
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
#>
param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[string]$TargetFramework = "net10.0",
[switch]$SelfContained,
[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(
[Parameter(Mandatory = $true)]
[string[]]$Candidates
)
foreach ($candidate in $Candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
return $null
}
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[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")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
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")
)
$ProjectFile = Get-FirstExistingFilePath -Candidates $candidateProjects
if ($null -eq $ProjectFile) {
$candidatesText = ($candidateProjects | ForEach-Object { " - $_" }) -join "`n"
Write-Error @"
Avalonia Linux Linux
docs/project/v1.2.0-tasks/01-Linux-AvaloniaWebView.md
$candidatesText
"@
exit 1
}
$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"
if (Test-Path $artifactRoot) {
Remove-Item -Recurse -Force $artifactRoot
}
New-Item -ItemType Directory -Path $publishDir | Out-Null
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
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
}
$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
}
$packageName = "hua.todo-$version-$RuntimeIdentifier.tar.gz"
$packagePath = Join-Path $artifactRoot $packageName
Write-Host "Creating tar.gz: $packageName" -ForegroundColor Cyan
tar -C $publishDir -czf $packagePath .
if ($LASTEXITCODE -ne 0) {
Write-Error "tar.gz packaging failed"
exit 1
}
Write-Host "Linux artifact created: $packagePath" -ForegroundColor Green
+450
View File
@@ -0,0 +1,450 @@
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,
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)),
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
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]$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")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
return $currentVersion
}
function Read-InnoSetupAppName {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript
)
if (!(Test-Path $SetupScript)) {
return $null
}
$content = Get-Content $SetupScript -Raw
$match = [regex]::Match($content, '#define\s+MyAppName\s+"([^"]+)"')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
return $null
}
# Read OutputBaseFilename from setup.iss so we can determine the exact installer file path after ISCC runs.
# The value may contain Inno preprocessor macros like {#MyAppName}/{#MyAppVersion}.
function Read-InnoSetupOutputBaseFilename {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript
)
if (!(Test-Path $SetupScript)) {
return $null
}
$content = Get-Content $SetupScript -Raw
$match = [regex]::Match($content, '^\s*OutputBaseFilename\s*=\s*(.+?)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline)
if ($match.Success) {
return $match.Groups[1].Value.Trim().Trim('"')
}
return $null
}
# Resolve the subset of Inno preprocessor macros used by this repo to a concrete file base name.
function Resolve-InnoSetupOutputBaseFilename {
param(
[Parameter(Mandatory = $true)]
[string]$Template,
[string]$AppName,
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Template)) {
return $null
}
$resolved = $Template
if (![string]::IsNullOrWhiteSpace($AppName)) {
$resolved = $resolved.Replace("{#MyAppName}", $AppName)
}
if (![string]::IsNullOrWhiteSpace($Version)) {
$resolved = $resolved.Replace("{#MyAppVersion}", $Version)
}
return $resolved.Trim()
}
function Update-InnoSetupVersion {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript,
[Parameter(Mandatory = $true)]
[string]$Version
)
if (!(Test-Path $SetupScript)) {
return
}
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $Version + '"'
$versionFound = $true
break
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent -Encoding UTF8
}
}
function Copy-Directory {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (Test-Path $Destination) {
Remove-Item -Recurse -Force $Destination
}
New-Item -ItemType Directory -Path $Destination | Out-Null
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
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
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 启用,其它目标关闭。
$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
}
$publishDir = Join-Path $ProjectDir ("bin\{0}\{1}\{2}\publish" -f $Configuration, $TargetFramework, $RuntimeIdentifier)
if (!(Test-Path $publishDir)) {
$publishDir = (Get-ChildItem -Path (Join-Path $ProjectDir "bin\$Configuration") -Recurse -Directory -Filter publish -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName)
}
if (!(Test-Path $publishDir)) {
Write-Error "Publish directory not found"
exit 1
}
$publishAppSettings = Join-Path $publishDir "appsettings.json"
if (Test-Path $publishAppSettings) {
$appSettingsObj = Get-Content $publishAppSettings -Raw | ConvertFrom-Json
if ($null -eq $appSettingsObj.WebServer) {
$appSettingsObj | Add-Member -MemberType NoteProperty -Name "WebServer" -Value ([pscustomobject]@{})
}
$appSettingsObj.WebServer.IsUsingStatic = $true
$appSettingsObj | ConvertTo-Json -Depth 32 | Set-Content -Path $publishAppSettings -Encoding UTF8
} else {
Write-Error "Publish appsettings.json not found: $publishAppSettings"
exit 1
}
$desiredExeName = "$ProjectBaseName.exe"
$desiredExePath = Join-Path $publishDir $desiredExeName
if (!(Test-Path $desiredExePath)) {
$candidateExe = $null
$preferredCandidateNames = @(
"Hua.Todo.Maui.exe",
"Hua.Todo.Avalonia.exe",
"Hua.Todo.exe"
)
foreach ($name in $preferredCandidateNames) {
$candidatePath = Join-Path $publishDir $name
if (Test-Path $candidatePath) {
$candidateExe = Get-Item -Path $candidatePath
break
}
}
if ($null -eq $candidateExe) {
$exeFiles = Get-ChildItem -Path $publishDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike "*Setup*" }
$candidateExe = $exeFiles | Where-Object { Test-Path (Join-Path $publishDir ($_.BaseName + ".dll")) } | Select-Object -First 1
if ($null -eq $candidateExe) {
$candidateExe = $exeFiles | Sort-Object Length -Descending | Select-Object -First 1
}
}
if ($null -ne $candidateExe) {
Rename-Item -Path $candidateExe.FullName -NewName $desiredExeName -Force
}
}
if (!(Test-Path $desiredExePath)) {
Write-Error "Publish main executable not found: $desiredExeName"
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
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
}
$installerPath = $null
if (!$SkipInnoSetup.IsPresent) {
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
$innoAppName = Read-InnoSetupAppName -SetupScript $SetupScript
$outputDir = Join-Path $ProjectDir "Output"
$outputBaseTemplate = Read-InnoSetupOutputBaseFilename -SetupScript $SetupScript
$resolvedOutputBase = Resolve-InnoSetupOutputBaseFilename -Template $outputBaseTemplate -AppName $innoAppName -Version $currentVersion
$expectedInstallerPath = $null
if (![string]::IsNullOrWhiteSpace($resolvedOutputBase)) {
$expectedInstallerPath = Join-Path $outputDir ("{0}.exe" -f $resolvedOutputBase)
}
if (![string]::IsNullOrWhiteSpace($innoAppName)) {
# Prevent stale unversioned installer (Output\Hua.Todo.exe) from confusing local release artifacts.
$oldInstallerPath = Join-Path $outputDir ("{0}.exe" -f $innoAppName)
if (Test-Path $oldInstallerPath) {
Remove-Item -Path $oldInstallerPath -Force
}
}
# 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) {
break
}
Write-Host $isccOutput
if ($attempt -lt $maxAttempts -and ($isccOutput -match 'Compile aborted|being used by another process|Sharing violation|process cannot access')) {
Start-Sleep -Seconds 2
continue
}
Write-Error "Packaging failed"
exit 1
}
if ($null -ne $expectedInstallerPath -and (Test-Path $expectedInstallerPath)) {
$installerPath = $expectedInstallerPath
}
if ($null -eq $installerPath) {
if (Test-Path $outputDir) {
$installerPath = (Get-ChildItem -Path $outputDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName)
}
}
Write-Host "Setup package created successfully!" -ForegroundColor Green
if ($null -ne $installerPath -and (Test-Path $installerPath)) {
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
}
} else {
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
}
}
if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
$publishArtifactDir = Join-Path $ArtifactsRoot "publish"
$installerArtifactDir = Join-Path $ArtifactsRoot "installer"
Copy-Directory -Source $publishDir -Destination $publishArtifactDir
if ($null -ne $installerPath -and (Test-Path $installerPath)) {
if (Test-Path $installerArtifactDir) {
Remove-Item -Recurse -Force $installerArtifactDir
}
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
$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
}
}
if (!$SkipVersionBump.IsPresent) {
$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)
# 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
}
}
+133 -48
View File
@@ -1,62 +1,147 @@
param(
[switch]$Windows,
[switch]$Linux,
[string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"),
[switch]$LinuxSelfContained,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
[switch]$SkipWindowsVersionBump
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
$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"
# Read version from project file
$currentVersion = "1.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
} else {
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
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
}
}
# Update setup script version with current version before build
if (Test-Path $SetupScript) {
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"'
$versionFound = $true
break
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 ($versionFound) {
Set-Content $SetupScript -Value $issContent
}
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
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishWindows = $true
$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) {
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) {
Write-Host "Publishing Linux Apps..." -ForegroundColor Cyan
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript -RuntimeIdentifier $rid -SelfContained:$LinuxSelfContained -Configuration $Configuration -SkipProcessStop
}
}
Write-Host "Building Hua.Todo.Maui (Release)..." -ForegroundColor Cyan
dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
exit 1
# 4. Finalizing
if (!$SkipWindowsVersionBump.IsPresent) {
Bump-Version
}
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
& $ISCC $SetupScript
if ($LASTEXITCODE -eq 0) {
Write-Host "Setup package created successfully!" -ForegroundColor Green
} else {
Write-Error "Packaging failed"
}
} else {
Write-Error "Inno Setup compiler not found"
}
$versionParts = $currentVersion.Split(".")
$patch = [int]$versionParts[2] + 1
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
$content = Get-Content $ProjectFile -Raw
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content
Write-Host "All publishing tasks completed!" -ForegroundColor Green
@@ -0,0 +1,53 @@
using System.Security.Claims;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// 获取当前用户 ID(若未登录则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>用户 ID。</returns>
public static Guid? GetUserId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 获取当前会话 ID(若不可用则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>会话 ID。</returns>
public static Guid? GetSessionId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.Sid);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 判断是否具备指定权限。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <param name="permission">权限点。</param>
/// <returns>是否具备权限。</returns>
public static bool HasPermission(this ClaimsPrincipal user, string permission)
{
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 判断是否已完成二次认证(step-up)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>是否已完成二次认证。</returns>
public static bool HasStepUp(this ClaimsPrincipal user)
{
return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权使用的 Claim 名称约定。
/// </summary>
public static class CloudClaims
{
/// <summary>
/// 权限 Claim(可重复出现)。
/// </summary>
public const string Permission = "perm";
/// <summary>
/// 二次认证状态 Claim。
/// </summary>
public const string StepUp = "step_up";
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步权限点常量。
/// </summary>
public static class CloudPermissions
{
/// <summary>
/// 读取任务数据。
/// </summary>
public const string TasksRead = "tasks:read";
/// <summary>
/// 写入任务数据。
/// </summary>
public const string TasksWrite = "tasks:write";
/// <summary>
/// 允许执行同步写入(用于区分“允许同步/禁止同步”)。
/// </summary>
public const string SyncWrite = "sync:write";
/// <summary>
/// 读取安全策略。
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// 修改安全策略(高风险)。
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// 管理用户(创建/修改角色)。
/// </summary>
public const string UsersManage = "users:manage";
}
@@ -0,0 +1,43 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 默认的角色-权限映射(内置最小集合)。
/// </summary>
public class DefaultRolePermissionMapper : IRolePermissionMapper
{
/// <inheritdoc />
public IReadOnlyList<string> GetPermissions(string role)
{
return role switch
{
"admin" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead,
CloudPermissions.PolicyWrite,
CloudPermissions.UsersManage
},
"readonly" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.PolicyRead
},
"nosync" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.PolicyRead
},
_ => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead
}
};
}
}
@@ -0,0 +1,15 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 角色到权限集合的映射器(RBAC 最小落地)。
/// </summary>
public interface IRolePermissionMapper
{
/// <summary>
/// 获取指定角色对应的权限集合。
/// </summary>
/// <param name="role">角色名称。</param>
/// <returns>权限列表。</returns>
IReadOnlyList<string> GetPermissions(string role);
}
@@ -0,0 +1,13 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步会话鉴权默认配置。
/// </summary>
public static class SessionAuthenticationDefaults
{
/// <summary>
/// 会话鉴权 Scheme 名称。
/// </summary>
public const string Scheme = "CloudSession";
}
@@ -0,0 +1,114 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Hua.Todo.Application.Data;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 基于服务端会话表的 Bearer Token 鉴权处理器。
/// </summary>
public class SessionAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly TodoDbContext _dbContext;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="SessionAuthenticationHandler"/>。
/// </summary>
/// <param name="options">鉴权 scheme 选项。</param>
/// <param name="logger">日志。</param>
/// <param name="encoder">编码器。</param>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
TodoDbContext dbContext,
IRolePermissionMapper rolePermissionMapper)
: base(options, logger, encoder)
{
_dbContext = dbContext;
_rolePermissionMapper = rolePermissionMapper;
}
/// <inheritdoc />
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
return AuthenticateResult.NoResult();
}
if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var token = authorization["Bearer ".Length..].Trim();
if (!Guid.TryParse(token, out var sessionId))
{
return AuthenticateResult.Fail("Invalid bearer token format.");
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions
.AsNoTracking()
.Include(s => s.User)
.FirstOrDefaultAsync(s => s.Id == sessionId, Context.RequestAborted);
if (session == null || session.User == null)
{
return AuthenticateResult.Fail("Session not found.");
}
if (session.ExpiresAtUtc <= now)
{
return AuthenticateResult.Fail("Session expired.");
}
var user = session.User;
var permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList();
var allowSync = await _dbContext.SecurityPolicies
.AsNoTracking()
.Where(p => p.UserId == user.Id)
.Select(p => (bool?)p.AllowSync)
.FirstOrDefaultAsync(Context.RequestAborted);
if (allowSync.HasValue && !allowSync.Value)
{
permissions.RemoveAll(p => string.Equals(p, CloudPermissions.SyncWrite, StringComparison.OrdinalIgnoreCase));
}
var claims = new List<Claim>
{
new(ClaimTypes.Sid, session.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Role, user.Role)
};
foreach (var p in permissions)
{
claims.Add(new Claim(CloudClaims.Permission, p));
}
if (session.StepUpExpiresAtUtc.HasValue && session.StepUpExpiresAtUtc.Value > now)
{
claims.Add(new Claim(CloudClaims.StepUp, "true"));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
@@ -0,0 +1,316 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端 API 路由注册扩展。
/// </summary>
public static class CloudSyncEndpointExtensions
{
/// <summary>
/// 映射云同步相关 API 端点。
/// </summary>
/// <param name="app">Web 应用。</param>
/// <returns>Web 应用。</returns>
public static WebApplication MapCloudSyncEndpoints(this WebApplication app)
{
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read");
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write");
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
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))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
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).");
}
return Results.Ok();
}
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))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
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.");
}
return Results.Json(response);
}
private static async Task<IResult> StepUpAsync(
StepUpRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("Password is required.");
}
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.");
}
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
}
private static async Task<IResult> GetTasksAsync(
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.TasksRead))
{
return CloudApiErrors.Forbidden();
}
var tasks = await taskService.GetTasksAsync(userId.Value, cancellationToken);
return Results.Json(tasks);
}
private static async Task<IResult> SyncAsync(
SyncRequest request,
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.SyncWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
return Results.Json(response);
}
private static async Task<IResult> GetPolicyAsync(
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyRead))
{
return CloudApiErrors.Forbidden();
}
var policy = await policyService.GetPolicyAsync(userId.Value, cancellationToken);
return Results.Json(policy);
}
private static async Task<IResult> UpdatePolicyAsync(
UpdateSecurityPolicyRequest request,
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
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
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端能力的依赖注入扩展。
/// </summary>
public static class CloudSyncServiceCollectionExtensions
{
/// <summary>
/// 注册云同步相关的认证、授权与业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncServer(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<IRolePermissionMapper, DefaultRolePermissionMapper>();
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudAdminService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
services.AddAuthorization(options =>
{
options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead));
options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite));
options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite));
options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead));
options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite));
options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage));
});
return services;
}
}
@@ -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; }
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 统一错误响应结构。
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// 错误码(用于客户端识别与提示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 错误消息(用于调试或直接展示)。
/// </summary>
public string Message { get; set; } = string.Empty;
}
@@ -0,0 +1,87 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 登录请求。
/// </summary>
public class LoginRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 登录响应。
/// </summary>
public class LoginResponse
{
/// <summary>
/// Bearer Token(会话 ID)。
/// </summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// 会话过期时间(UTC)。
/// </summary>
public DateTime ExpiresAtUtc { get; set; }
/// <summary>
/// 用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 用户角色。
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// 用户权限列表。
/// </summary>
public List<string> Permissions { get; set; } = new();
}
/// <summary>
/// 初始化管理员账号请求(仅在系统尚无云用户时可用)。
/// </summary>
public class BootstrapAdminRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)请求。
/// </summary>
public class StepUpRequest
{
/// <summary>
/// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)响应。
/// </summary>
public class StepUpResponse
{
/// <summary>
/// 二次认证有效期截止(UTC)。
/// </summary>
public DateTime StepUpExpiresAtUtc { get; set; }
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 错误响应生成器。
/// </summary>
public static class CloudApiErrors
{
/// <summary>
/// 生成标准错误响应。
/// </summary>
/// <param name="statusCode">HTTP 状态码。</param>
/// <param name="code">业务错误码。</param>
/// <param name="message">错误消息。</param>
/// <returns>最小 API 结果。</returns>
public static IResult Error(int statusCode, string code, string message)
{
return Results.Json(
new ApiErrorResponse { Code = code, Message = message },
statusCode: statusCode);
}
/// <summary>
/// 未认证。
/// </summary>
public static IResult Unauthorized(string message = "Unauthorized.")
=> Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message);
/// <summary>
/// 权限不足。
/// </summary>
public static IResult Forbidden(string message = "Forbidden.")
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message);
/// <summary>
/// 需要二次认证(step-up)。
/// </summary>
public static IResult SecondFactorRequired(string message = "Second factor required.")
=> Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", message);
/// <summary>
/// 请求非法。
/// </summary>
public static IResult BadRequest(string message = "Bad request.")
=> Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message);
/// <summary>
/// 资源不存在。
/// </summary>
public static IResult NotFound(string message = "Not found.")
=> Error(StatusCodes.Status404NotFound, "NOT_FOUND", message);
}
@@ -0,0 +1,58 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 安全策略下发 DTO。
/// </summary>
public class SecurityPolicyDto
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
/// <summary>
/// 更新安全策略请求 DTO。
/// </summary>
public class UpdateSecurityPolicyRequest
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
@@ -0,0 +1,108 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步任务条目。
/// </summary>
public class CloudTaskItem
{
/// <summary>
/// 任务 ID(服务端分配)。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; }
/// <summary>
/// 父任务 ID(v1.2.0 同步可先不使用)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
/// <summary>
/// 新增或更新的任务列表。
/// </summary>
public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary>
/// 需要删除的任务 ID 列表。
/// </summary>
public List<int> Deletes { get; set; } = new();
}
/// <summary>
/// 任务 Upsert DTO。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为空表示新建。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
/// <summary>
/// 服务端时间(UTC)。
/// </summary>
public DateTime ServerTimeUtc { get; set; }
/// <summary>
/// 当前用户的任务全量。
/// </summary>
public List<CloudTaskItem> Tasks { get; set; } = new();
}
@@ -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
}
@@ -0,0 +1,233 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Auth;
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 CloudAuthService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="CloudAuthService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public CloudAuthService(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_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, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return false;
}
var hasAnyCloudUser = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.Role != "local", cancellationToken);
if (hasAnyCloudUser)
{
await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return false;
}
var exists = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists)
{
return false;
}
var now = DateTime.UtcNow;
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin",
CreatedAtUtc = now,
UpdatedAtUtc = now
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
_dbContext.Users.Add(user);
_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;
}
/// <summary>
/// 用户名密码登录并创建会话。
/// </summary>
/// <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, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return null;
}
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;
}
var now = DateTime.UtcNow;
var expiresAt = now.Add(sessionTtl);
var session = new UserSessionEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
CreatedAtUtc = now,
ExpiresAtUtc = expiresAt
};
_dbContext.UserSessions.Add(session);
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (policy == null)
{
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(),
ExpiresAtUtc = expiresAt,
UserId = user.Id,
Role = user.Role,
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList()
};
}
/// <summary>
/// 通过再输入口令提升会话权限(step-up)。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</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, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return null;
}
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 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;
}
}
@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
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 CloudTaskSyncService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public CloudTaskSyncService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的任务全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>任务列表。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{
var tasks = await _dbContext.Tasks
.AsNoTracking()
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync(cancellationToken);
return tasks.Select(MapToItem).ToList();
}
/// <summary>
/// 执行同步(增改删),并返回最新全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="request">同步请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>同步响应。</returns>
public async Task<SyncResponse> SyncAsync(Guid userId, SyncRequest request, CancellationToken cancellationToken)
{
request ??= new SyncRequest();
if (request.Deletes.Count > 0)
{
var deleteIds = request.Deletes.Distinct().ToList();
var toDelete = await _dbContext.Tasks
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
.ToListAsync(cancellationToken);
if (toDelete.Count > 0)
{
_dbContext.Tasks.RemoveRange(toDelete);
}
}
if (request.Upserts.Count > 0)
{
foreach (var upsert in request.Upserts)
{
if (string.IsNullOrWhiteSpace(upsert.Title))
{
continue;
}
if (upsert.Id.HasValue)
{
var existing = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (existing != null)
{
existing.Title = upsert.Title.Trim();
existing.Priority = upsert.Priority;
existing.IsCompleted = upsert.IsCompleted;
existing.ParentTaskId = upsert.ParentTaskId;
existing.UpdatedAt = DateTime.UtcNow;
continue;
}
}
var entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = upsert.ParentTaskId
};
_dbContext.Tasks.Add(entity);
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new SyncResponse
{
ServerTimeUtc = DateTime.UtcNow,
Tasks = await GetTasksAsync(userId, cancellationToken)
};
}
private static CloudTaskItem MapToItem(TaskEntity task)
{
return new CloudTaskItem
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAtUtc = task.CreatedAt,
UpdatedAtUtc = task.UpdatedAt,
ParentTaskId = task.ParentTaskId
};
}
}
@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
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 SecurityPolicyService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的安全策略。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>策略 DTO。</returns>
public async Task<SecurityPolicyDto> GetPolicyAsync(Guid userId, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies
.AsNoTracking()
.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
return new SecurityPolicyDto
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
};
}
/// <summary>
/// 更新指定用户的安全策略(覆盖式更新)。
/// </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, 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,
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);
return await GetPolicyAsync(userId, 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);
}
}
+98 -2
View File
@@ -1,34 +1,130 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
using Hua.Todo.Application.Data.Converters;
namespace Hua.Todo.Application.Data;
/// <summary>
/// 应用程序数据库上下文(EF Core)。
/// </summary>
public class TodoDbContext : DbContext
{
/// <summary>
/// 创建 <see cref="TodoDbContext"/>。
/// </summary>
/// <param name="options">数据库上下文配置。</param>
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
/// <summary>
/// 任务集合。
/// </summary>
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 用户集合。
/// </summary>
public DbSet<UserEntity> Users { get; set; }
/// <summary>
/// 用户会话集合。
/// </summary>
public DbSet<UserSessionEntity> UserSessions { get; set; }
/// <summary>
/// 安全策略集合。
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 安全审计日志集合。
/// </summary>
public DbSet<AuditLogEntity> AuditLogs { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("Tasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
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)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserName).IsRequired().HasMaxLength(64);
entity.HasIndex(e => e.UserName).IsUnique();
entity.Property(e => e.PasswordHash).IsRequired();
entity.Property(e => e.Role).IsRequired().HasMaxLength(32);
});
modelBuilder.Entity<UserSessionEntity>(entity =>
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
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()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SecurityPolicyEntity>(entity =>
{
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);
});
}
}
@@ -2,8 +2,16 @@ using Microsoft.AspNetCore.Builder;
namespace Hua.Todo.Application.DynamicApi;
/// <summary>
/// Dynamic API 中间件扩展方法。
/// </summary>
public static class DynamicApiExtensions
{
/// <summary>
/// 注册 Dynamic API 中间件。
/// </summary>
/// <param name="builder">应用构建器。</param>
/// <returns>应用构建器。</returns>
public static IApplicationBuilder UseDynamicApi(this IApplicationBuilder builder)
{
return builder.UseMiddleware<DynamicApiMiddleware>();
@@ -6,6 +6,10 @@ using Hua.Todo.Application.Interfaces;
namespace Hua.Todo.Application.DynamicApi;
/// <summary>
/// Dynamic API 中间件。
/// 根据路由约定将 <see cref="IDynamicApiService"/> 的接口方法映射为 HTTP API/api/{service}/...)。
/// </summary>
public class DynamicApiMiddleware
{
private readonly RequestDelegate _next;
@@ -44,12 +48,21 @@ public class DynamicApiMiddleware
}
}
/// <summary>
/// 创建 <see cref="DynamicApiMiddleware"/>。
/// </summary>
/// <param name="next">管道中的下一个中间件。</param>
/// <param name="serviceProvider">应用根服务容器。</param>
public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
_next = next;
_serviceProvider = serviceProvider;
}
/// <summary>
/// 处理当前请求并尝试进行 Dynamic API 分发。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
@@ -1,14 +1,27 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP GET。
/// </summary>
public class HttpGetAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpGetAttribute"/>。
/// </summary>
public HttpGetAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpGetAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpGetAttribute(string route)
{
Route = route;
@@ -16,14 +29,27 @@ public class HttpGetAttribute : Attribute
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP POST。
/// </summary>
public class HttpPostAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpPostAttribute"/>。
/// </summary>
public HttpPostAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpPostAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpPostAttribute(string route)
{
Route = route;
@@ -31,14 +57,27 @@ public class HttpPostAttribute : Attribute
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP PUT。
/// </summary>
public class HttpPutAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpPutAttribute"/>。
/// </summary>
public HttpPutAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpPutAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpPutAttribute(string route)
{
Route = route;
@@ -46,14 +85,27 @@ public class HttpPutAttribute : Attribute
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP DELETE。
/// </summary>
public class HttpDeleteAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpDeleteAttribute"/>。
/// </summary>
public HttpDeleteAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpDeleteAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpDeleteAttribute(string route)
{
Route = route;
@@ -61,14 +113,27 @@ public class HttpDeleteAttribute : Attribute
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP PATCH。
/// </summary>
public class HttpPatchAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpPatchAttribute"/>。
/// </summary>
public HttpPatchAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpPatchAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpPatchAttribute(string route)
{
Route = route;
@@ -1,11 +1,17 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
/// <summary>
/// 指示参数从 QueryString 绑定。
/// </summary>
public class FromQueryAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
/// <summary>
/// 指示参数从 Request Body 绑定。
/// </summary>
public class FromBodyAttribute : Attribute
{
}
@@ -1,8 +1,17 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记服务/方法是否允许通过 Dynamic API 暴露。
/// </summary>
public class RemoteServiceAttribute : Attribute
{
/// <summary>
/// 是否启用远程访问。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 是否启用元数据输出。
/// </summary>
public bool IsMetadataEnabled { get; set; } = true;
}
@@ -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,10 +12,12 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="CloudSync\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
@@ -1,5 +1,9 @@
namespace Hua.Todo.Application.Interfaces;
/// <summary>
/// Dynamic API 服务标记接口。
/// 实现该接口的服务会被 Dynamic API 中间件发现并按约定暴露为 HTTP API。
/// </summary>
public interface IDynamicApiService
{
}
@@ -2,15 +2,67 @@ using Hua.Todo.Application.Models;
namespace Hua.Todo.Application.Interfaces;
/// <summary>
/// 任务管理服务接口
/// </summary>
public interface ITaskService : IDynamicApiService
{
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>包含所有任务的列表</returns>
Task<List<TaskDto>> GetAllTasksAsync();
/// <summary>
/// 根据 ID 获取任务
/// </summary>
/// <param name="id">任务唯一标识符</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(int id);
/// <summary>
/// 获取未完成的任务
/// </summary>
/// <returns>未完成任务的列表</returns>
Task<List<TaskDto>> GetActiveTasksAsync();
/// <summary>
/// 获取已完成的任务
/// </summary>
/// <returns>已完成任务的列表</returns>
Task<List<TaskDto>> GetCompletedTasksAsync();
/// <summary>
/// 创建新任务
/// </summary>
/// <param name="dto">创建任务所需的数据传输对象</param>
/// <returns>创建成功的任务 DTO</returns>
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
/// <summary>
/// 更新任务信息
/// </summary>
/// <param name="dto">包含更新信息的任务数据传输对象</param>
/// <returns>更新后的任务 DTO</returns>
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
/// <summary>
/// 切换任务完成状态
/// </summary>
/// <param name="id">任务唯一标识符</param>
/// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(int id);
/// <summary>
/// 删除任务
/// </summary>
/// <param name="id">要删除的任务唯一标识符</param>
Task DeleteTaskAsync(int id);
/// <summary>
/// 获取子任务列表
/// </summary>
/// <param name="parentTaskId">父任务唯一标识符</param>
/// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
}
@@ -0,0 +1,198 @@
// <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("20260406172936_AddCloudSyncCoreEntities")]
partial class AddCloudSyncCoreEntities
{
/// <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<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<DateTime>("CreatedAt")
.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<DateTime>("UpdatedAt")
.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<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.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<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("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,136 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddCloudSyncCoreEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "UserId",
table: "Tasks",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000001"));
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "UserName", "PasswordHash", "Role" },
values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "local", "", "local" });
migrationBuilder.CreateTable(
name: "SecurityPolicies",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AllowPersist = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecurityPolicies", x => x.Id);
table.ForeignKey(
name: "FK_SecurityPolicies_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StepUpExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSessions", x => x.Id);
table.ForeignKey(
name: "FK_UserSessions_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tasks_UserId",
table: "Tasks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SecurityPolicies_UserId",
table: "SecurityPolicies",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_UserName",
table: "Users",
column: "UserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_UserId",
table: "UserSessions",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks");
migrationBuilder.DropTable(
name: "SecurityPolicies");
migrationBuilder.DropTable(
name: "UserSessions");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropIndex(
name: "IX_Tasks_UserId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "UserId",
table: "Tasks");
}
}
}
@@ -0,0 +1,203 @@
// <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("20260406173734_AddAllowSyncToSecurityPolicy")]
partial class AddAllowSyncToSecurityPolicy
{
/// <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<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<DateTime>("CreatedAt")
.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<DateTime>("UpdatedAt")
.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<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.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<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAllowSyncToSecurityPolicy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSync",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSync",
table: "SecurityPolicies");
}
}
}
@@ -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);
}
}
}
@@ -1,9 +1,9 @@
// <auto-generated />
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
@@ -17,13 +17,97 @@ 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")
.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<DateTime>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
@@ -46,16 +130,96 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
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.ToTable("Tasks");
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 =>
@@ -65,13 +229,37 @@ namespace Hua.Todo.Application.Migrations
.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
}
}
@@ -3,45 +3,112 @@ using System.Text.Json.Serialization;
namespace Hua.Todo.Application.Models;
/// <summary>
/// 创建任务请求 DTO。
/// </summary>
public class CreateTaskDto
{
/// <summary>
/// 任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 父任务 ID(用于创建子任务)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 更新任务请求 DTO。
/// </summary>
public class UpdateTaskDto
{
/// <summary>
/// 任务 ID。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 新标题(可选)。
/// </summary>
public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 新优先级(可选)。
/// </summary>
public TaskPriority? Priority { get; set; }
}
/// <summary>
/// 任务返回 DTO。
/// </summary>
public class TaskDto
{
/// <summary>
/// 任务 ID。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否已完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
/// <summary>
/// 子任务列表。
/// </summary>
public List<TaskDto> SubTasks { get; set; } = new();
}
/// <summary>
/// 通用 API 响应包装。
/// </summary>
/// <typeparam name="T">数据类型。</typeparam>
public class ApiResponse<T>
{
/// <summary>
/// 是否成功。
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 返回数据(可选)。
/// </summary>
public T? Data { get; set; }
/// <summary>
/// 提示信息。
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// 错误列表。
/// </summary>
public List<string> Errors { get; set; } = new();
}
@@ -5,52 +5,88 @@ using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Repositories;
/// <summary>
/// 任务仓储实现(EF Core)。
/// </summary>
public class TaskRepository : ITaskRepository
{
private readonly TodoDbContext _context;
/// <summary>
/// 创建 <see cref="TaskRepository"/>。
/// </summary>
/// <param name="context">数据库上下文。</param>
public TaskRepository(TodoDbContext context)
{
_context = context;
}
/// <summary>
/// 获取所有任务。
/// </summary>
/// <returns>包含所有任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId)
.Include(t => t.SubTasks)
.ToListAsync();
}
/// <summary>
/// 根据 ID 获取任务。
/// </summary>
/// <param name="id">任务 ID。</param>
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
public async Task<TaskEntity?> GetByIdAsync(int id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
}
/// <summary>
/// 获取未完成任务列表。
/// </summary>
/// <returns>未完成的任务实体列表。</returns>
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
/// <summary>
/// 获取已完成任务列表。
/// </summary>
/// <returns>已完成的任务实体列表。</returns>
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
/// <summary>
/// 新增一个任务。
/// </summary>
/// <param name="taskEntity">要添加的任务实体。</param>
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
taskEntity.UserId = TodoUserIds.LocalUserId;
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
/// <summary>
/// 更新现有任务信息。
/// </summary>
/// <param name="taskEntity">要更新的任务实体。</param>
/// <returns>更新后的任务实体。</returns>
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{
taskEntity.UpdatedAt = DateTime.UtcNow;
@@ -59,9 +95,14 @@ public class TaskRepository : ITaskRepository
return taskEntity;
}
/// <summary>
/// 根据 ID 删除任务。
/// </summary>
/// <param name="id">要删除的任务 ID。</param>
/// <returns>表示删除操作的任务。</returns>
public async Task DeleteAsync(int id)
{
var task = await _context.Tasks.FindAsync(id);
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
if (task != null)
{
_context.Tasks.Remove(task);
@@ -69,10 +110,15 @@ public class TaskRepository : ITaskRepository
}
}
/// <summary>
/// 获取指定父任务的子任务列表。
/// </summary>
/// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
@@ -9,8 +9,17 @@ using ITaskService = Hua.Todo.Application.Interfaces.ITaskService;
namespace Hua.Todo.Application;
/// <summary>
/// 应用层依赖注入扩展。
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册应用层服务与 EF Core DbContext。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="connectionString">数据库连接字符串。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddApplicationServices(this IServiceCollection services, string connectionString)
{
services.AddDbContext<TodoDbContext>(options =>
@@ -5,39 +5,68 @@ using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Services;
/// <summary>
/// 任务管理服务实现
/// </summary>
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
/// <summary>
/// 初始化任务管理服务的新实例
/// </summary>
/// <param name="taskRepository">任务仓储接口</param>
public TaskService(ITaskRepository taskRepository)
{
_taskRepository = taskRepository;
}
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>所有任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetAllTasksAsync()
{
var tasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList();
}
/// <summary>
/// 根据 ID 获取任务详情
/// </summary>
/// <param name="id">任务 ID</param>
/// <returns>找到的任务 DTO,如果不存在则返回 null</returns>
public async Task<TaskDto?> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null;
}
/// <summary>
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetActiveTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => !t.IsCompleted).Select(MapToDto).ToList();
}
/// <summary>
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetCompletedTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.IsCompleted).Select(MapToDto).ToList();
}
/// <summary>
/// 创建新任务
/// </summary>
/// <param name="dto">任务创建 DTO</param>
/// <returns>新创建的任务 DTO</returns>
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{
var task = new TaskEntity
@@ -54,6 +83,12 @@ public class TaskService : ITaskService
return MapToDto(createdTask);
}
/// <summary>
/// 更新现有任务
/// </summary>
/// <param name="dto">包含更新内容的 DTO</param>
/// <returns>更新后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{
var task = await _taskRepository.GetByIdAsync(dto.Id);
@@ -78,6 +113,12 @@ public class TaskService : ITaskService
return MapToDto(updatedTask);
}
/// <summary>
/// 切换任务的完成状态
/// </summary>
/// <param name="id">任务 ID</param>
/// <returns>状态切换后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> ToggleCompleteAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
@@ -93,6 +134,11 @@ public class TaskService : ITaskService
return MapToDto(updatedTask);
}
/// <summary>
/// 删除指定 ID 的任务
/// </summary>
/// <param name="id">任务 ID</param>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task DeleteTaskAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
@@ -104,12 +150,20 @@ public class TaskService : ITaskService
await _taskRepository.DeleteAsync(id);
}
/// <summary>
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务 ID</param>
/// <returns>子任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId)
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
}
/// <summary>
/// 实体转 DTO 映射方法
/// </summary>
private TaskDto MapToDto(TaskEntity task)
{
return new TaskDto
+15
View File
@@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Hua.Todo.Avalonia.App"
xmlns:local="using:Hua.Todo.Avalonia"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+308
View File
@@ -0,0 +1,308 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using AvaloniaWebView;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using Hua.Todo.Avalonia.Services.Platforms;
using Hua.Todo.Avalonia.ViewModels;
using Hua.Todo.Avalonia.Views;
using System;
using System.ComponentModel;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
namespace Hua.Todo.Avalonia;
/// <summary>
/// Avalonia 应用入口。
/// 负责启动嵌入式 WebServer、创建主窗口并初始化 WebView 能力。
/// </summary>
public partial class App : global::Avalonia.Application
{
private AppSettings? _appSettings;
private IEmbeddedWebServerService? _webServer;
private IHotKeySettingsService? _hotKeySettingsService;
private IGlobalHotKeyService? _hotKeyService;
private HotKeyConfig? _hotKeyConfig;
private WindowsKeyboardHandler? _keyboardHandler;
private MainWindow? _mainWindow;
private bool _isExitRequested;
private bool _isHotkeyRegistered;
/// <summary>
/// 初始化应用资源并加载配置。
/// </summary>
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
LoadSettings();
}
/// <summary>
/// 注册应用级服务。
/// </summary>
public override void RegisterServices()
{
base.RegisterServices();
AvaloniaWebViewBuilder.Initialize(default);
}
private void LoadSettings()
{
try
{
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
_appSettings = JsonSerializer.Deserialize<AppSettings>(json);
}
}
catch (Exception ex)
{
Console.WriteLine($"[App] Failed to load settings: {ex.Message}");
}
_appSettings ??= new AppSettings();
EnsureDefaultConnectionString(_appSettings);
}
private static void EnsureDefaultConnectionString(AppSettings appSettings)
{
if (string.IsNullOrWhiteSpace(appSettings.WebServer.ConnectionString))
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dbDir = Path.Combine(localAppData, "Hua.Todo");
if (!Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
var dbPath = Path.Combine(dbDir, "Hua.Todo.db");
appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared";
}
}
/// <summary>
/// Avalonia 框架初始化完成回调。
/// 启动嵌入式 WebServer(后台启动并捕获异常,避免阻塞/崩溃),并创建主窗口。
/// </summary>
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
_ = Task.Run(async () =>
{
try
{
await _webServer.StartAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[App] Web server start failed: {ex}");
}
});
_hotKeySettingsService = new HotKeySettingsService(_appSettings!);
_hotKeyConfig = _hotKeySettingsService.GetConfig();
_hotKeyService = GlobalHotKeyServiceFactory.Create();
if (OperatingSystem.IsWindows())
{
_keyboardHandler = new WindowsKeyboardHandler();
_keyboardHandler.EscKeyPressed += (_, _) =>
{
Dispatcher.UIThread.Post(() =>
{
if (_mainWindow is { IsVisible: true })
{
_mainWindow.WindowState = WindowState.Minimized;
}
});
};
}
// 只在支持的平台上初始化系统托盘
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
{
var trayViewModel = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
InitializeTrayIcon(trayViewModel);
}
_mainWindow = new MainWindow(_appSettings!, _webServer)
{
Width = 450,
Height = 640,
Title = AppMetadata.GetWindowTitle(),
};
_mainWindow.Opened += (_, _) =>
{
RegisterHotkey();
_keyboardHandler?.Start();
};
_mainWindow.Closing += (_, e) =>
{
if (_isExitRequested)
{
return;
}
e.Cancel = true;
_mainWindow.Hide();
};
desktop.MainWindow = _mainWindow;
desktop.Exit += async (s, e) =>
{
_keyboardHandler?.Dispose();
_keyboardHandler = null;
if (_isHotkeyRegistered)
{
_hotKeyService?.UnregisterHotKey();
_isHotkeyRegistered = false;
}
if (_hotKeyService is IDisposable disposableHotKeyService)
{
disposableHotKeyService.Dispose();
}
if (_webServer != null)
{
await _webServer.StopAsync();
}
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
singleView.MainView = new MainView(_appSettings!, _webServer);
}
base.OnFrameworkInitializationCompleted();
}
private void ExitApplication(IClassicDesktopStyleApplicationLifetime desktop)
{
_isExitRequested = true;
desktop.Shutdown();
}
private void ShowMainWindow()
{
if (_mainWindow == null) return;
if (!_mainWindow.IsVisible)
{
_mainWindow.Show();
}
if (_mainWindow.WindowState == WindowState.Minimized)
{
_mainWindow.WindowState = WindowState.Normal;
}
_mainWindow.Activate();
}
private void RegisterHotkey()
{
if (_hotKeyService == null || _hotKeySettingsService == null) return;
var config = _hotKeyConfig ?? _hotKeySettingsService.GetConfig();
_hotKeyConfig = config;
if (_hotKeyService.IsSupported && config.IsEnabled)
{
_hotKeyService.RegisterHotKey(config.Modifiers, config.Key, () =>
{
Dispatcher.UIThread.Post(ShowMainWindow);
});
_isHotkeyRegistered = true;
}
config.PropertyChanged -= OnHotKeyConfigPropertyChanged;
config.PropertyChanged += OnHotKeyConfigPropertyChanged;
}
private void OnHotKeyConfigPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_hotKeyService == null || _hotKeySettingsService == null || _hotKeyConfig == null) return;
if (e.PropertyName != nameof(HotKeyConfig.Modifiers) &&
e.PropertyName != nameof(HotKeyConfig.Key) &&
e.PropertyName != nameof(HotKeyConfig.IsEnabled))
{
return;
}
_hotKeySettingsService.SaveConfig(_hotKeyConfig);
if (_hotKeyConfig.IsEnabled && _hotKeyService.IsSupported)
{
_hotKeyService.UpdateHotKey(_hotKeyConfig.Modifiers, _hotKeyConfig.Key);
_isHotkeyRegistered = true;
}
else
{
_hotKeyService.UnregisterHotKey();
_isHotkeyRegistered = false;
}
}
private void InitializeTrayIcon(AppTrayViewModel trayViewModel)
{
try
{
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}");
}
}
}
@@ -0,0 +1,96 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<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>
<TodoWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web'))</TodoWebDir>
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<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="icon.ico" />
</ItemGroup>
<ItemGroup>
<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.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>
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<Target Name="BuildTodoWeb" BeforeTargets="BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoWebDistDir)\index.html'))">
<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" WorkingDirectory="$(TodoWebDir)" />
</Target>
<Target Name="CopyTodoWebDistToWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="Exists('$(TodoWebDistDir)')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
<MakeDir Directories="$(TargetDir)wwwroot" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace Hua.Todo.Avalonia.Models;
public class AppSettings
{
[JsonPropertyName("WebServer")]
public WebServerSettings WebServer { get; set; } = new();
[JsonPropertyName("HotKey")]
public HotKeyDefaultSettings HotKey { get; set; } = new();
}
public class WebServerSettings
{
[JsonPropertyName("Port")]
public int Port { get; set; } = 5057;
[JsonPropertyName("IsUsingStatic")]
public bool IsUsingStatic { get; set; } = true;
[JsonPropertyName("ConnectionString")]
public string ConnectionString { get; set; } = "";
[JsonPropertyName("HostUrl")]
public string HostUrl { get; set; } = "http://localhost:5057";
[JsonPropertyName("ForEndUrl")]
public string ForEndUrl { get; set; } = "http://localhost:5174";
}
public class HotKeyDefaultSettings
{
[JsonPropertyName("DefaultModifiers")]
public string DefaultModifiers { get; set; } = "Alt";
[JsonPropertyName("DefaultKey")]
public string DefaultKey { get; set; } = "X";
[JsonPropertyName("DefaultIsEnabled")]
public bool DefaultIsEnabled { get; set; } = true;
}
@@ -0,0 +1,72 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Hua.Todo.Avalonia.Models;
/// <summary>
/// 热键配置模型。
/// 用于描述全局热键的修饰键、主键与启用状态,并通过通知机制驱动动态更新。
/// </summary>
public sealed class HotKeyConfig : INotifyPropertyChanged
{
private string _modifiers = "Alt";
private string _key = "X";
private bool _isEnabled = true;
/// <summary>
/// 修饰键(例如 Alt、Control、Shift、Windows;多个键用逗号分隔)。
/// </summary>
public string Modifiers
{
get => _modifiers;
set
{
if (_modifiers != value)
{
_modifiers = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 主键(例如 X、C、V)。
/// </summary>
public string Key
{
get => _key;
set
{
if (_key != value)
{
_key = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 是否启用热键。
/// </summary>
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
@@ -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();
}
}
+31
View File
@@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.WebView.Desktop;
using Hua.Todo.Avalonia.Services;
using Hua.Todo.Avalonia.Models;
using System;
namespace Hua.Todo.Avalonia;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
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()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseDesktopWebView();
}
@@ -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

Before

Width:  |  Height:  |  Size: 50 KiB

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,60 @@
using System.Reflection;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 应用元信息工具类。
/// 负责生成窗口标题与托盘提示文本等展示字符串,避免在多处重复拼装并保持跨宿主一致性。
/// </summary>
public static class AppMetadata
{
private const string AppNameText = "待办事项";
/// <summary>
/// 应用名称(不含版本)。
/// </summary>
public static string AppName => AppNameText;
/// <summary>
/// 获取显示版本号(主版本.次版本.修订版本)。
/// 若无法解析版本信息则返回 null。
/// </summary>
public static string? GetDisplayVersion()
{
var asmVersion = Assembly.GetExecutingAssembly().GetName().Version;
if (asmVersion == null)
{
return null;
}
return $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}";
}
/// <summary>
/// 获取托盘/关于等场景使用的显示标题(可能包含版本)。
/// </summary>
public static string GetDisplayTitle()
{
var version = GetDisplayVersion();
return string.IsNullOrWhiteSpace(version) ? AppName : $"{AppName} v{version}";
}
/// <summary>
/// 获取窗口标题栏文本(可能包含版本)。
/// </summary>
public static string GetWindowTitle()
{
var version = GetDisplayVersion();
return string.IsNullOrWhiteSpace(version) ? AppNameText : $"{AppNameText} v{version}";
}
/// <summary>
/// 获取托盘 Tooltip 文本(部分平台对长度有限制)。
/// </summary>
public static string GetTrayTooltipText()
{
var text = GetDisplayTitle();
return text.Length > 63 ? text[..63] : text;
}
}
@@ -0,0 +1,208 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Hua.Todo.Application;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Avalonia.Models;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 嵌入式 Web 服务器实现
/// </summary>
public class EmbeddedWebServerService : IEmbeddedWebServerService
{
private WebApplication? _webApp;
private readonly AppSettings _appSettings;
/// <summary>
/// 服务器是否正在运行
/// </summary>
public bool IsRunning => _webApp != null;
/// <summary>
/// 服务器基础 URL
/// </summary>
public string BaseUrl => _appSettings.WebServer.HostUrl;
/// <summary>
/// 初始化嵌入式 Web 服务器服务
/// </summary>
/// <param name="appSettings">应用程序配置</param>
public EmbeddedWebServerService(AppSettings appSettings)
{
_appSettings = appSettings;
}
/// <summary>
/// 异步启动服务器
/// </summary>
public async Task StartAsync()
{
if (_webApp != null) return;
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl);
// 配置控制器和 JSON 选项
builder.Services.AddControllers()
.AddApplicationPart(typeof(Hua.Todo.Application.ServiceCollectionExtensions).Assembly)
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddEndpointsApiExplorer();
// 注册应用逻辑服务
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
// 配置跨域策略
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
InitializeDatabase(app);
// 如果配置为使用静态文件(前端托管),则配置静态文件服务
if (_appSettings.WebServer.IsUsingStatic)
{
ServeStaticFiles(app);
}
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseDynamicApi();
app.MapControllers();
_webApp = app;
await _webApp.StartAsync();
}
private void InitializeDatabase(WebApplication app)
{
using var scope = app.Services.CreateScope();
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString);
var actualDbPath = sqliteBuilder.DataSource;
if (!string.IsNullOrEmpty(actualDbPath))
{
var dbDir = Path.GetDirectoryName(actualDbPath);
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
}
dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
dbContext.Database.Migrate();
}
catch (Exception ex)
{
Console.WriteLine($"[EmbeddedWebServer] Database initialization failed: {ex.Message}");
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
dbContext.Database.EnsureCreated();
}
catch (Exception ensureEx)
{
Console.WriteLine($"[EmbeddedWebServer] Database ensure-created failed: {ensureEx.Message}");
}
}
}
/// <summary>
/// 配置静态文件服务(用于托管 Vue 前端)
/// </summary>
/// <param name="app">Web 应用程序实例</param>
private void ServeStaticFiles(WebApplication app)
{
try
{
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(wwwrootPath))
{
Console.WriteLine($"[EmbeddedWebServer] wwwroot directory not found at: {wwwrootPath}. Static file serving disabled.");
return;
}
var fileProvider = new PhysicalFileProvider(wwwrootPath);
var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider, RequestPath = "" };
app.UseDefaultFiles(defaultFilesOptions);
var staticFileOptions = new StaticFileOptions
{
FileProvider = fileProvider,
RequestPath = "",
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
ctx.Context.Response.Headers["Pragma"] = "no-cache";
ctx.Context.Response.Headers["Expires"] = "0";
}
};
app.UseStaticFiles(staticFileOptions);
// 处理 SPA 路由
app.Use(async (context, next) =>
{
if (context.Request.Path.HasValue)
{
var path = context.Request.Path.Value;
if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
{
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
{
context.Request.Path = "/index.html";
}
}
}
await next();
});
Console.WriteLine($"[EmbeddedWebServer] Serving static files from: {wwwrootPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[EmbeddedWebServer] Failed to serve static files: {ex.Message}");
}
}
/// <summary>
/// 异步停止服务器
/// </summary>
public async Task StopAsync()
{
if (_webApp == null) return;
await _webApp.StopAsync();
await _webApp.DisposeAsync();
_webApp = null;
}
}
@@ -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);
}
}

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