Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dbd97103c | |||
| 1f87565d5a | |||
| d94d1f36d2 | |||
| d53828c150 | |||
| 8443d14ba6 | |||
| 04263dff4e | |||
| 7a4c516a20 | |||
| 18d37fdd24 | |||
| 141b3112a4 | |||
| d00a907da0 |
@@ -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
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup Condition="'$(TargetFramework)' != ''">
|
||||
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RID(win-*/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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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.0:MAUI + 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();
|
||||
@@ -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 交流群)
|
||||
@@ -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`)
|
||||
- **基础 URL(MAUI 内嵌模式)**: `{HostUrl}/api`(`HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`)
|
||||
- **数据格式**: JSON
|
||||
- **认证方式**: 暂无(本地应用)
|
||||
- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证)
|
||||
- **跨域配置**: 允许本地跨域请求
|
||||
|
||||
### 5.2 API 端点设计
|
||||
|
||||
#### 任务管理 API
|
||||
```
|
||||
GET /api/tasks # 获取任务列表
|
||||
GET /api/tasks/{id} # 获取单个任务
|
||||
POST /api/tasks # 创建任务
|
||||
PUT /api/tasks/{id} # 更新任务
|
||||
DELETE /api/tasks/{id} # 删除任务
|
||||
PATCH /api/tasks/{id}/complete # 标记任务完成
|
||||
GET /api/task # 获取任务列表(默认:全部)
|
||||
GET /api/task/active # 获取未完成任务
|
||||
GET /api/task/completed # 获取已完成任务
|
||||
GET /api/task/{id} # 获取单个任务
|
||||
POST /api/task # 创建任务
|
||||
PUT /api/task # 更新任务(通过 Body 内的 id 定位)
|
||||
DELETE /api/task/{id} # 删除任务
|
||||
PATCH /api/task/{id}/toggle # 切换完成状态
|
||||
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
|
||||
```
|
||||
|
||||
#### 设置管理 API
|
||||
#### 云同步 API(Host 模式)
|
||||
```
|
||||
GET /api/settings # 获取设置
|
||||
PUT /api/settings # 更新设置
|
||||
```
|
||||
|
||||
#### 同步 API
|
||||
```
|
||||
POST /api/sync/pull # 拉取远程数据
|
||||
POST /api/sync/push # 推送本地数据
|
||||
POST /auth/bootstrap # 初始化管理员(仅首次)
|
||||
POST /auth/login # 登录
|
||||
POST /auth/step-up # 二次验证(提升权限)
|
||||
GET /tasks/ # 获取云端任务(只读)
|
||||
POST /sync/ # 推送/拉取合并同步
|
||||
GET /security/policy # 获取安全策略
|
||||
PUT /security/policy # 更新安全策略
|
||||
```
|
||||
|
||||
## 6. 数据库设计
|
||||
@@ -306,6 +368,7 @@ CREATE TABLE Settings (
|
||||
- **Windows**: WebView2 运行时要求
|
||||
- **macOS**: 代码签名和公证
|
||||
- **移动端**: 应用商店发布配置
|
||||
- **Linux**: .NET MAUI 无官方 Linux 目标,需要引入独立桌面宿主(例如基于 WebKitGTK 的方案)并处理运行时依赖与打包格式
|
||||
|
||||
## 9. 性能优化
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 版本更新历史
|
||||
|
||||
## 🔄 版本更新
|
||||
|
||||
### 版本策略
|
||||
|
||||
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
|
||||
- v1.0.0:初始 WPF 版本
|
||||
- v1.1.0:MAUI + 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.0(2026-04-07)
|
||||
|
||||
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
|
||||
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
|
||||
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
|
||||
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
|
||||
- **MAUI(Windows)内嵌 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 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
|
||||
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
|
||||
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
|
||||
- **文档与部署指南**:新增 `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` 发布脚本与 Flatpak(manifest/desktop entry/AppStream)基础结构。
|
||||
- **关键词检索**:支持按任务标题关键词搜索。
|
||||
- **标签系统**:引入多标签支持,提升任务组织效率。
|
||||
- **暗色模式**:全平台适配暗色/深色主题。
|
||||
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
|
||||
@@ -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 Runtime(Windows)或 WebKitGTK(Linux)。
|
||||
- **同步连接失败**:
|
||||
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 - Linux:Avalonia 入口 + 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 App(ClassicDesktopLifetime)
|
||||
|
||||
### 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/*`(至少任务列表接口可用)
|
||||
- 页面路由/刷新不会出现 404(SPA 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 / 云同步提供可复现的验收步骤
|
||||
|
||||
## 范围
|
||||
|
||||
- README:Features、运行方式、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] 高风险操作触发二次认证(服务端支持时)
|
||||
|
||||
@@ -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 的依赖库可能不一致。
|
||||
- **凭据与合规**:第三方服务鉴权方式差异大,且必须保证凭据安全存储与最小化权限。
|
||||
- **终端可信度与数据落盘**:若需支持“不信任终端不落盘”,需要明确“可信度判定与配置下发”的服务端策略,并保证客户端在无落盘场景下的可用性与体验。
|
||||
@@ -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" "$@"
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -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.gz;Flatpak/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-Avalonia入口与WebView.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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 8601(Round-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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
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 使用 Kestrel,Android 可使用 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);
|
||||
}
|
||||
}
|
||||
|
||||