Compare commits

..

4 Commits

Author SHA1 Message Date
ShaoHua 8443d14ba6 发布版本 2026-04-08 20:16:04 +08:00
ShaoHua 04263dff4e refactor: 重构待办事项模块结构与命名 2026-04-08 19:59:50 +08:00
ShaoHua 7a4c516a20 feat: 引入 CloudSync 核心能力并新增 Avalonia 桌面端与发布脚本
- 后端:新增 CloudSync 认证/权限/端点/服务与 DTO
- 数据:新增用户/会话/安全策略实体与 EF Core migrations
- 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot
- 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键
- 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置
- 文档:同步更新 README/docs 与协作规则
2026-04-07 03:34:34 +08:00
ShaoHua 18d37fdd24 doc:输出1.2.0版本需求文档 2026-04-07 00:36:39 +08:00
100 changed files with 6871 additions and 231 deletions
+4
View File
@@ -366,3 +366,7 @@ 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
-33
View File
@@ -1,33 +0,0 @@
---
alwaysApply: true
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
---
# 注释规范(必须遵守)
## 通用
- 新增或修改的代码必须包含足够注释,使“不了解该模块的人”也能理解其职责、边界与关键决策。
- 优先使用 **XML 文档注释**`///`),而不是随意的行内注释。
- 不允许无意义注释(例如“初始化变量”“进入方法”)。注释必须解释“为什么/约束/边界/副作用”。
- 不允许出现“TODO/FIXME”但无上下文或无处理方案的注释。
## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途
- 对关键参数/返回值:`param` / `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
-**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
-**异步/后台任务**
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
-**安全/隐私**
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
## TypeScript / Vue(前端)
- 对导出的函数/类型必须有注释,解释用途与输入输出。
- 对“与后端/MAUI 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
-34
View File
@@ -1,34 +0,0 @@
---
alwaysApply: true
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
---
# 文档同步规范(必须遵守)
## 通用原则
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
- **协作友好(局部修改)**:当并行处理多个任务/需求时,更新文档应尽量只修改与本任务直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
## 更新范围
- **README.md**
- 如果变更涉及核心功能点(Features)、安装步骤(Installation)、快速开始(Quick Start)或 API 端点(API Endpoints),必须同步更新。
- 变更涉及技术栈调整或项目结构变化时需更新。
- **docs/ 目录文档**
- **接口变更**:若修改了 API,需同步更新 [技术设计文档](docs/技术设计文档.md) 中的接口部分。
- **功能新增/调整**:需在 [产品需求文档](docs/产品需求文档.md) 和 [技术栈与模块](docs/技术栈与模块.md) 中体现。
- **架构/模式变更**:需更新 [技术设计文档](docs/技术设计文档.md)。
- **代码规范**:若引入了新的编码模式或工具,需更新 [代码规范文档](docs/代码规范文档.md)。
- **版本记录**:所有非琐碎的变更必须在 [版本记录.md](docs/版本记录.md) 中添加记录。
## 检查清单
1. [ ] 是否有新增的 API 端点?(更新 README 和技术设计文档)
2. [ ] 是否修改了现有的业务逻辑或数据结构?(更新技术设计文档)
3. [ ] 是否有新增的功能模块?(更新产品需求文档和技术栈说明)
4. [ ] 是否调整了开发环境或依赖?(更新 README)
5. [ ] 是否在 [版本记录.md](docs/版本记录.md) 中记录了本次变更?
6. [ ] 文档变更是否保持“局部修改”,只影响与本任务相关的段落/小节?(避免无关重排/改写)
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True)">true</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
</PropertyGroup>
</Project>
+2 -3
View File
@@ -6,9 +6,8 @@
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj">
<Build Solution="Debug|*" Project="true" />
</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">
+36 -8
View File
@@ -1,16 +1,17 @@
# Hua.Todo 跨平台代办管理应用
一个基于 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(预览)
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示)
## 📦 安装与使用
@@ -37,6 +38,7 @@ dotnet restore
dotnet run
```
API 将在 `http://localhost:5173` 启动
开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`
#### 3. 启动前端 Web
```bash
@@ -46,6 +48,27 @@ npm run dev
```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
#### 4. 启动 MAUI 客户端(Windows 三件套开发)
- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)``Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。
- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI):
```powershell
.\start-dev.ps1
```
### Windows 交付产物(安装包)
- 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`
- 安装后主程序为:`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/`
### 使用说明
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
@@ -57,6 +80,7 @@ npm run dev
### 项目结构
```
Hua.Todo/
├── pack/ # 打包与交付产物(Linux/安装包等)
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ └── project/ # 项目进度/需求文档
@@ -66,18 +90,22 @@ Hua.Todo/
│ ├── 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` - 获取子任务列表
## 🤝 交流与贡献
+11 -11
View File
@@ -314,7 +314,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 +457,7 @@ export const useTaskStore = defineStore('tasks', () => {
async function fetchTasks() {
loading.value = true;
try {
const response = await fetch('/api/tasks');
const response = await fetch('/api/task');
tasks.value = await response.json();
} catch (err) {
error.value = 'Failed to fetch tasks';
@@ -481,30 +481,30 @@ export const useTaskStore = defineStore('tasks', () => {
### 6.1 RESTful API 设计
```csharp
// 使用名词复数形式
[HttpGet("tasks")]
// 本项目的任务端点采用 Dynamic API 路由约定:/api/task(由中间件反射分发)
[HttpGet("task")]
public async Task<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 +563,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 +609,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();
+4 -1
View File
@@ -5,7 +5,7 @@
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **UI 框架**MAUI (Multi-platform App UI)
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
@@ -37,3 +37,6 @@
### Hua.Todo.Maui
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
### Hua.Todo.Avalonia
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
+29 -20
View File
@@ -173,22 +173,28 @@ Hua.Todo/
- WebView 容器管理
- 本地 HTTP 服务器启动
**调试与接口文档**
- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。
**关键组件**:
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
- `App.xaml.cs`: 应用程序主入口
- `WebViewContainer`: 封装 WebView 控件
- 平台特定服务: 快捷键、通知等
### 4.2 后端 API 项目 (Hua.Todo.Api)
### 4.2 后端 API 项目 (Hua.Todo.Host)
**职责**:
- 提供 RESTful API 接口
- 业务逻辑处理
- 数据访问和持久化
- 本地 HTTP 服务器托管
**接口文档**
- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。
**关键组件**:
- `Controllers`: API 端点实现
- `Services`: 业务逻辑服务
- `CloudSync`: 云同步 Minimal APIs`/auth``/tasks``/sync``/security`
- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API`/api/{service}/...`)对外暴露
- `Data`: 数据访问层和数据库上下文
- `Program.cs`: API 服务器配置和启动
@@ -220,33 +226,36 @@ Hua.Todo/
## 5. HTTP API 设计
### 5.1 API 基础配置
- **基础 URL**: `http://localhost:5000/api`
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`
- **基础 URLMAUI 内嵌模式)**: `{HostUrl}/api``HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`
- **数据格式**: JSON
- **认证方式**: 暂无(本地应用
- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证
- **跨域配置**: 允许本地跨域请求
### 5.2 API 端点设计
#### 任务管理 API
```
GET /api/tasks # 获取任务列表
GET /api/tasks/{id} # 获取单个任务
POST /api/tasks # 创建任务
PUT /api/tasks/{id} # 更新任务
DELETE /api/tasks/{id} # 删除任务
PATCH /api/tasks/{id}/complete # 标记任务完成
GET /api/task # 获取任务列表(默认:全部)
GET /api/task/active # 获取未完成任务
GET /api/task/completed # 获取已完成任务
GET /api/task/{id} # 获取单个任务
POST /api/task # 创建任务
PUT /api/task # 更新任务(通过 Body 内的 id 定位)
DELETE /api/task/{id} # 删除任务
PATCH /api/task/{id}/toggle # 切换完成状态
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
```
#### 设置管理 API
#### 云同步 APIHost 模式)
```
GET /api/settings # 获取设置
PUT /api/settings # 更新设置
```
#### 同步 API
```
POST /api/sync/pull # 拉取远程数据
POST /api/sync/push # 推送本地数据
POST /auth/bootstrap # 初始化管理员(仅首次)
POST /auth/login # 登录
POST /auth/step-up # 二次验证(提升权限)
GET /tasks/ # 获取云端任务(只读)
POST /sync/ # 推送/拉取合并同步
GET /security/policy # 获取安全策略
PUT /security/policy # 更新安全策略
```
## 6. 数据库设计
+16 -1
View File
@@ -9,6 +9,21 @@
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### v1.2.0(开发中,2026-04-07
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
- **MAUIWindows)内嵌 API 文档**Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。
- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作“后端专用路径”排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。
- **MAUI 多平台构建开关**:在 Windows 开发机上默认仅构建 Android + Windows 目标,避免 iOS/MacCatalyst 目标在非 macOS 环境触发运行时包缺失(NETSDK1082);在 macOS 上仍会包含 iOS/MacCatalyst 目标。
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`
- **Windows 发布打包修复**Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。
- **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
### v1.1.1 (2026-04-06)
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
@@ -29,8 +44,8 @@
### v1.2.0 规划内容 (即将推出)
- **Linux 官方支持**:正式适配 Linux 平台。
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpakmanifest/desktop entry/AppStream)基础结构。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
@@ -0,0 +1,49 @@
# Hua.Todo v1.2.0 任务拆分总览
本目录用于把 [产品需求文档-1.2.0.md](file:///d:/Proj/6.Hua.Todo/docs/project/产品需求文档-1.2.0.md) 拆解为可落地、可并行推进的子任务。各任务文件之间尽量解耦;存在明确依赖时会在任务内标注。
## 目标(来自 PRD
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有“本地 API ↔ 前端”的交互协议
- 任务检索(Search):主界面顶部新增搜索框,按任务标题模糊匹配
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的“可控落盘”
## 当前实现基线(用于任务定位)
- MAUI 启动与内嵌 WebServer`src/Hua.Todo.Maui`
- 前端(Vue)与 API client`src/Hua.Todo.Web`
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__``window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
## 任务流(并行建议)
- **Linux 入口线**`01-*` + `02-*`
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1``pack/linux/`
- **Search 线**`03-*`
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
- 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
- **云同步线**`04-*` + `05-*` + `06-*`
- 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离)
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务)
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与“内存模式”)
- **文档与验收线**`07-*`
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
## 待验证表
| 子任务 | 实现状态 | 验证状态 | 备注 |
|---|---|---|---|
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增“云同步设置”弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 |
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
| 07 - 文档与验收 | 未标注 | 待验证 | |
## 交付判定(v1.2.0 Done Definition
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
- Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发“是否允许落盘”;客户端在禁止落盘时不产生本地持久化
@@ -0,0 +1,93 @@
# 01 - LinuxAvalonia 入口 + WebView 承载 Vue
## 目标
- 新增一个 **Linux 桌面端入口**Avalonia)作为 Hua.Todo 的 Linux 宿主
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
- 复用既有“前端 ↔ 本地 API”的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
## 范围
- 新增/调整项目结构以容纳 Avalonia 入口(建议新项目:`src/Hua.Todo.Avalonia``src/Hua.Todo.Desktop.Avalonia`
- 在 Avalonia 中接入可在 Linux 运行的 WebView 控件(需要先调研与选型)
- 确保能正确加载:
- 内嵌 WebServer 的 `BaseUrl`
- 以及前端静态资源(开发态/生产态策略需明确)
## 依赖
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`
## 关键决策点(必须在实现前落定)
### 1) Avalonia/Linux 可用的 WebView 方案选型
候选方向(示例,最终以调研结果为准):
- WebKitGTK 系(Linux 依赖较重,但适配面广)
- Chromium 系(体积/依赖/许可成本需评估)
选型必须满足的最低能力:
- 基础导航与本地资源加载
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证“HTTP API”路径通)
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
### 2) 资源加载策略(与 MAUI 对齐)
需要明确并实现两种模式:
- 开发态:Avalonia WebView 指向前端 dev server(例如 `http://localhost:5173`)或指向本地 WebServer
- 生产态:加载随应用交付的静态资源(`wwwroot`)并确保 SPA fallback 生效(访问非 `/assets/*` 的路由能回到 `index.html`
## 实现落地(已完成)
### 1) 项目结构
- 新增 Linux 桌面端入口项目:`src/Hua.Todo.Avalonia`
- 入口类型:Avalonia Desktop AppClassicDesktopLifetime
### 2) WebView 方案选型(已落定)
- 采用:`WebView.Avalonia` + `WebView.Avalonia.Desktop`
- Windows:依赖 WebView2 Runtime
- Linux:依赖 GTK + WebKitGTK(运行环境缺失时 WebView 会初始化失败)
### 3) 资源加载策略(与 MAUI 对齐)
- 生产态(默认):内嵌 WebServer 托管 `wwwroot` 静态资源,WebView 指向 `HostUrl`
- 开发态:将 `IsUsingStatic=false`WebView 指向 `ForEndUrl`(例如 Vite dev server
### 4) 前端契约对齐(与 MAUI 一致)
- 在 WebView 导航完成后注入:
- `window.__API_BASE_URL__ = "${HostUrl}/api"`
- `window.mauiInterop`(事件名/字段名与现有前端保持一致)
### 5) 本地 API 与数据库初始化
- 内嵌 WebServer 使用 Kestrel 启动并注册动态 API 与 Controllers
- 启动时执行数据库迁移(Migrate),并设置 SQLite WAL 模式以降低锁冲突风险
- 默认 SQLite 路径使用用户目录(`LocalApplicationData/Hua.Todo/Hua.Todo.db`),避免安装目录无写权限导致启动失败
## 实施步骤(建议)
1. 新建 Avalonia 宿主项目
- 提供 App/Window/MainView 基础结构
2. 接入 WebView 控件并完成最小加载闭环
- 能显示 `index.html`,能打开前端路由
3. 对齐与 MAUI 端一致的“前端契约”
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
4. 复用本地 API(内嵌 WebServer
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
- 明确 Linux 端是否也走“内嵌 WebServer + WebView 指向 BaseUrl”的方式(推荐一致化,减少前端差异)
5. 输入法/字体/DPI 验证与可配置化
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
## 验收标准
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
- WebView 正常渲染前端主界面
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
- 页面路由/刷新不会出现 404SPA fallback 生效)
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
@@ -0,0 +1,41 @@
# 02 - Linux:打包、依赖与交付产物
## 目标
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到“用户机器无需手动补大量依赖即可运行”
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
## 范围
- 构建脚本与产物输出
- 运行时依赖打包策略(尤其是 WebView 相关)
- 基础安装/运行说明(README / docs 更新)
## 依赖
- 依赖 `01-*`Avalonia 入口与 WebView 方案已定、并能在开发机运行)
## 交付形式(PRD 约束)
- 优先提供一种“自包含”官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
- 同时保留 `.deb``.tar.gz` 作为补充分发形式(以脚本产出为准)
## 实施步骤(建议)
1. 确定 Linux 发行版基线与运行时依赖清单
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
2. 选择并落地一种自包含交付形式
- AppImage:偏“拎包即用”,对依赖捆绑要求高
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
3. 补充 `.deb``.tar.gz`
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
- `.tar.gz`:最通用;需要启动脚本与依赖说明
4. 增加启动前自检(可选但推荐)
- 发现关键依赖缺失时给出清晰错误提示与修复建议
## 验收标准
- 产物可在“干净环境”(尽量接近用户机)安装/运行
- 启动后 WebView 能渲染前端,且本地 API 可用
- 文档中给出的安装/运行步骤可复现,且与产物一致
@@ -0,0 +1,45 @@
# 03 - Search:任务标题关键词检索
## 目标
- 在主界面顶部增加搜索框
- 支持按任务标题进行模糊匹配(实时过滤)
## 范围
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
- 过滤逻辑:对“树状任务”给出明确策略
- 性能:任务量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
## 依赖
- 不依赖 Linux/Avalonia 或云同步,可独立并行完成
## 过滤策略(需在实现时确定,并写入实现说明)
树状任务(父子任务)常见两种策略,任选其一并保持一致:
1. **命中即显示(含上下文)**
- 任意节点标题命中则显示该节点
- 若子任务命中,可同时显示其祖先链(便于理解层级)
2. **扁平化命中**
- 只显示命中的任务(可能丢失层级语义)
建议优先采用“命中即显示(含上下文)”,用户可更快定位。
## 实施步骤(建议)
1. 确定搜索框放置位置(主界面顶部)
2. 增加 `searchQuery` 状态与派生 `filteredTasks`
3. 将过滤逻辑接入现有列表渲染
4. 补充交互细节
- 输入时实时过滤
- 清空按钮
- Esc 清空、保持输入焦点(可选)
## 验收标准
- 输入关键字后,任务列表实时过滤,仅展示命中结果(符合上述策略)
- 清空后恢复原列表
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
@@ -0,0 +1,145 @@
# 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 }`
## 关键实现点(建议)
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,56 @@
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与“可控落盘”
## 目标(PRD 约束)
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整“是否允许落盘”等)
- 客户端读取服务端安全配置,决定任务信息是否允许落盘
- 当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据
## 范围
- 客户端:落盘策略切换(持久化 ↔ 内存)、高风险操作二次确认/二次认证 UI
- 服务端:策略下发 + 二次认证挑战/校验接口(与 `04-*` 协作)
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
### 1) “落盘”定义与边界
需要明确“哪些数据算落盘”,并在禁止落盘时全部避免:
- 任务数据(列表/详情)
- 同步状态(上次同步时间、待同步队列等)
- 登录凭据(token/refresh token/会话标识)
### 2) 本地存储抽象
建议把前端(或宿主)本地存储封装为可替换实现:
- 允许落盘:使用现有 localStorage/SQLite 等
- 禁止落盘:使用内存实现(刷新/退出即清空)
要求:
- 切换策略时行为可预期(例如从允许 → 禁止:立即清空已落盘数据并提示用户)
- 不在日志或 UI 中暴露敏感信息
### 3) 二次认证(挑战-响应)
建议采用“服务端驱动”的挑战流程:
- 客户端发起高风险操作
- 服务端返回“需要二次认证”的响应(包含 challenge 信息)
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
- 客户端携带证明再次提交
若服务端暂不支持二次认证,也需有“能力探测/降级提示”,避免客户端卡死。
## 验收标准
- 服务端返回 `allowPersist=false` 时:
- 客户端不会把任务数据与凭据写入任何持久化介质
- 退出应用后重新进入,任务数据为空(需重新登录/拉取)
- 对指定高风险操作:
- 无二次认证证明时被拒绝,并提示需要二次认证
- 提供正确二次认证后操作成功
@@ -0,0 +1,39 @@
# 07 - 文档同步与验收清单(v1.2.0)
## 目标
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
- 为 Linux / Search / 云同步提供可复现的验收步骤
## 范围
- READMEFeatures、运行方式、API 说明(如涉及)
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
## 必做同步点(结合当前仓库现状)
- **API 端点与项目结构**:对齐实际实现(避免文档仍描述不存在的项目或旧端点)
- **Linux 入口说明**:新增 Avalonia 入口的构建/运行/打包方式
- **云同步说明**:服务端地址配置、登录、落盘策略与安全机制(RBAC/二次认证)的用户可理解描述
- **版本记录**:在 `docs/版本记录.md` 增加 v1.2.0 非琐碎变更条目
## 验收清单(建议逐项勾选)
### Linux
- [ ] Linux 上可启动并打开主界面
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
### Search
- [ ] 主界面有搜索框
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [ ] 清空后恢复原列表
### 云同步(基础可用)
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
- [ ] 登录后能拉取该用户任务并展示
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [ ] 高风险操作触发二次认证(服务端支持时)
+115
View File
@@ -0,0 +1,115 @@
<#
Hua.Todo Linux 发布脚本(产出 .tar.gz
目标:
- 为 Linux 提供可分发的目录产物(dotnet publish 输出)
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链
#>
param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[switch]$SelfContained,
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
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
)
$currentVersion = "0.0.0"
[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
}
$candidateProjects = @(
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj")
)
$ProjectFile = Get-FirstExistingFilePath -Candidates $candidateProjects
if ($null -eq $ProjectFile) {
$candidatesText = ($candidateProjects | ForEach-Object { " - $_" }) -join "`n"
Write-Error @"
Avalonia Linux Linux
docs/project/v1.2.0-tasks/01-Linux-AvaloniaWebView.md
$candidatesText
"@
exit 1
}
$version = Read-ProjectVersion -ProjectFile $ProjectFile
$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" }
Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan
dotnet publish $ProjectFile `
-c $Configuration `
-r $RuntimeIdentifier `
--self-contained $selfContainedValue `
-o $publishDir
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed"
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
+325
View File
@@ -0,0 +1,325 @@
param(
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
[string]$RuntimeIdentifier = "win-x64",
[string]$Configuration = "Release",
[switch]$SkipInnoSetup,
[switch]$SkipVersionBump,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier))
)
$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"
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
)
$currentVersion = "0.0.0"
[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
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile
Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion
Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
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.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
}
$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++) {
$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-Error "Inno Setup compiler not found"
exit 1
}
}
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
$installerName = "hua.todo-$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)
$content = Get-Content $ProjectFile -Raw
if ($content -match "<Version>[^<]*</Version>") {
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content -Encoding UTF8
} else {
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
}
} else {
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
}
}
+53 -52
View File
@@ -1,62 +1,63 @@
param(
[switch]$Windows,
[switch]$Linux,
[string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"),
[switch]$LinuxSelfContained,
[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"
# 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
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
$shouldPublishWindows = $Windows.IsPresent
$shouldPublishLinux = $Linux.IsPresent
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishWindows = $true
$shouldPublishLinux = $true
}
if ($shouldPublishWindows) {
if (!(Test-Path $publishWindowsScript)) {
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
exit 1
}
& $publishWindowsScript `
-Configuration $Configuration `
-SkipInnoSetup:$SkipWindowsInnoSetup `
-SkipVersionBump:$SkipWindowsVersionBump
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
# 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
if ($shouldPublishLinux) {
if (!(Test-Path $publishLinuxScript)) {
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
exit 1
}
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript `
-RuntimeIdentifier $rid `
-SelfContained:$LinuxSelfContained `
-Configuration $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent
}
}
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
}
$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
@@ -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,194 @@
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).AllowAnonymous();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).AllowAnonymous();
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
return app;
}
private static async Task<IResult> BootstrapAdminAsync(
BootstrapAdminRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, 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,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), 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 expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), 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, cancellationToken);
return Results.Json(policy);
}
}
@@ -0,0 +1,47 @@
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<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,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,38 @@
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>
/// 更新安全策略请求 DTO。
/// </summary>
public class UpdateSecurityPolicyRequest
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { 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,188 @@
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;
}
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, 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)
{
return false;
}
var exists = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists)
{
return false;
}
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin"
};
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);
return true;
}
/// <summary>
/// 用户名密码登录并创建会话。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="sessionTtl">会话有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录响应;失败则返回 null。</returns>
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, 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")
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
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 hasPolicy = await _dbContext.SecurityPolicies
.AsNoTracking()
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
if (!hasPolicy)
{
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
}
await _dbContext.SaveChangesAsync(cancellationToken);
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="stepUpTtl">二次认证有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, 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)
{
return null;
}
var stepUpExpiresAt = now.Add(stepUpTtl);
session.StepUpExpiresAtUtc = stepUpExpiresAt;
await _dbContext.SaveChangesAsync(cancellationToken);
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,69 @@
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" }
};
}
/// <summary>
/// 更新指定用户的安全策略(覆盖式更新)。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, 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 };
_dbContext.SecurityPolicies.Add(policy);
}
else
{
policy.AllowPersist = allowPersist;
policy.AllowSync = allowSync;
}
await _dbContext.SaveChangesAsync(cancellationToken);
return await GetPolicyAsync(userId, cancellationToken);
}
}
@@ -21,6 +21,21 @@ public class TodoDbContext : DbContext
/// </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>
@@ -33,16 +48,58 @@ public class TodoDbContext : DbContext
{
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.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();
entity.Property(e => e.ExpiresAtUtc).IsRequired();
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.AllowPersist).HasDefaultValue(true);
entity.Property(e => e.AllowSync).HasDefaultValue(true);
entity.HasIndex(e => e.UserId).IsUnique();
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -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;
}
}
@@ -9,10 +9,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>
@@ -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");
}
}
}
@@ -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,6 +17,33 @@ namespace Hua.Todo.Application.Migrations
#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")
@@ -51,11 +78,82 @@ namespace Hua.Todo.Application.Migrations
.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<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 =>
@@ -65,13 +163,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
}
}
@@ -28,6 +28,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId)
.Include(t => t.SubTasks)
.ToListAsync();
}
@@ -41,7 +42,7 @@ public class TaskRepository : ITaskRepository
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
}
/// <summary>
@@ -51,7 +52,7 @@ public class TaskRepository : ITaskRepository
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();
}
@@ -63,7 +64,7 @@ public class TaskRepository : ITaskRepository
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();
}
@@ -75,6 +76,7 @@ public class TaskRepository : ITaskRepository
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
taskEntity.UserId = TodoUserIds.LocalUserId;
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
@@ -100,7 +102,7 @@ public class TaskRepository : ITaskRepository
/// <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);
@@ -116,7 +118,7 @@ public class TaskRepository : ITaskRepository
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();
}
+33
View File
@@ -0,0 +1,33 @@
<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"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
x:DataType="vm:AppTrayViewModel"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="{Binding TrayTooltipText}"
Command="{Binding ShowMainWindowCommand}">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+270
View File
@@ -0,0 +1,270 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
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.Linq;
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)
{
DisableAvaloniaDataAnnotationValidation();
_webServer = new EmbeddedWebServerService(_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;
}
});
};
}
DataContext = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
_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();
}
};
}
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 DisableAvaloniaDataAnnotationValidation()
{
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,64 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<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>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</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));
}
}
+23
View File
@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.WebView.Desktop;
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) => 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,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,25 @@
using Hua.Todo.Avalonia.Services.Platforms;
using System;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 全局热键服务工厂。
/// 通过运行时平台判断返回最合适的实现,避免在同一文件内混写大量条件编译代码。
/// </summary>
public static class GlobalHotKeyServiceFactory
{
/// <summary>
/// 创建平台对应的全局热键服务实现。
/// </summary>
public static IGlobalHotKeyService Create()
{
if (OperatingSystem.IsWindows())
{
return new WindowsGlobalHotKeyService();
}
return new NoopGlobalHotKeyService();
}
}
@@ -0,0 +1,130 @@
using Hua.Todo.Avalonia.Models;
using System;
using System.IO;
using System.Text.Json;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 热键设置服务接口。
/// 负责读取/保存用户可配置的全局热键,并提供从默认值重置的能力。
/// </summary>
public interface IHotKeySettingsService
{
/// <summary>
/// 获取当前热键配置(若尚未持久化则返回默认配置)。
/// </summary>
HotKeyConfig GetConfig();
/// <summary>
/// 保存热键配置。
/// </summary>
/// <param name="config">要保存的热键配置。</param>
void SaveConfig(HotKeyConfig config);
/// <summary>
/// 重置为默认配置并落盘。
/// </summary>
void ResetToDefault();
}
/// <summary>
/// 热键设置服务实现。
/// Avalonia 侧使用本地 JSON 文件持久化配置,以保证桌面端与多平台运行时一致性。
/// </summary>
public sealed class HotKeySettingsService : IHotKeySettingsService
{
private const string SettingsFileName = "hotkey.json";
private readonly AppSettings _appSettings;
private HotKeyConfig? _cached;
/// <summary>
/// 创建 <see cref="HotKeySettingsService"/>。
/// </summary>
/// <param name="appSettings">应用配置(用于提供默认热键)。</param>
public HotKeySettingsService(AppSettings appSettings)
{
_appSettings = appSettings;
}
/// <inheritdoc />
public HotKeyConfig GetConfig()
{
if (_cached != null)
{
return _cached;
}
var path = GetSettingsPath();
if (!File.Exists(path))
{
_cached = GetDefaultConfig();
return _cached;
}
try
{
var json = File.ReadAllText(path);
_cached = JsonSerializer.Deserialize<HotKeyConfig>(json) ?? GetDefaultConfig();
return _cached;
}
catch
{
_cached = GetDefaultConfig();
return _cached;
}
}
/// <inheritdoc />
public void SaveConfig(HotKeyConfig config)
{
var path = GetSettingsPath();
EnsureSettingsDirectory(path);
var json = JsonSerializer.Serialize(config);
File.WriteAllText(path, json);
_cached ??= config;
}
/// <inheritdoc />
public void ResetToDefault()
{
var defaultConfig = GetDefaultConfig();
var current = GetConfig();
current.Modifiers = defaultConfig.Modifiers;
current.Key = defaultConfig.Key;
current.IsEnabled = defaultConfig.IsEnabled;
SaveConfig(current);
}
private HotKeyConfig GetDefaultConfig()
{
return new HotKeyConfig
{
Modifiers = _appSettings.HotKey.DefaultModifiers,
Key = _appSettings.HotKey.DefaultKey,
IsEnabled = _appSettings.HotKey.DefaultIsEnabled
};
}
private static string GetSettingsPath()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dir = Path.Combine(localAppData, "Hua.Todo");
return Path.Combine(dir, SettingsFileName);
}
private static void EnsureSettingsDirectory(string settingsPath)
{
var dir = Path.GetDirectoryName(settingsPath);
if (string.IsNullOrWhiteSpace(dir))
{
return;
}
Directory.CreateDirectory(dir);
}
}
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 嵌入式 Web 服务器接口
/// </summary>
public interface IEmbeddedWebServerService
{
/// <summary>
/// 服务器是否正在运行
/// </summary>
bool IsRunning { get; }
/// <summary>
/// 服务器基础 URL
/// </summary>
string BaseUrl { get; }
/// <summary>
/// 异步启动服务器
/// </summary>
Task StartAsync();
/// <summary>
/// 异步停止服务器
/// </summary>
Task StopAsync();
}
@@ -0,0 +1,36 @@
using System;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 全局热键服务接口。
/// 用于在桌面环境下注册系统级快捷键,以便在应用隐藏/最小化时快速唤起主窗口。
/// </summary>
public interface IGlobalHotKeyService
{
/// <summary>
/// 注册全局热键。
/// </summary>
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
/// <param name="key">主键(如 X、C)。</param>
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
void RegisterHotKey(string modifiers, string key, Action callback);
/// <summary>
/// 注销已注册的热键。
/// </summary>
void UnregisterHotKey();
/// <summary>
/// 更新热键配置。
/// </summary>
/// <param name="modifiers">新的修饰键。</param>
/// <param name="key">新的主键。</param>
void UpdateHotKey(string modifiers, string key);
/// <summary>
/// 当前平台是否支持全局热键。
/// </summary>
bool IsSupported { get; }
}
@@ -0,0 +1,29 @@
using System;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 不支持全局热键的平台默认实现。
/// 该实现不会注册任何系统级热键,用于保持上层逻辑简洁一致。
/// </summary>
public sealed class NoopGlobalHotKeyService : IGlobalHotKeyService
{
/// <inheritdoc />
public void RegisterHotKey(string modifiers, string key, Action callback)
{
}
/// <inheritdoc />
public void UnregisterHotKey()
{
}
/// <inheritdoc />
public void UpdateHotKey(string modifiers, string key)
{
}
/// <inheritdoc />
public bool IsSupported => false;
}
@@ -0,0 +1,264 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Windows 平台全局热键服务实现。
/// 通过 <c>RegisterHotKey</c> 将热键注册到专用线程的消息队列中,避免依赖窗口句柄与 WndProc Hook。
/// </summary>
public sealed class WindowsGlobalHotKeyService : IGlobalHotKeyService, IDisposable
{
private const int HotKeyId = 9000;
private const uint WmHotKey = 0x0312;
private const uint WmQuit = 0x0012;
private const uint WmAppExecute = 0x8001;
public const uint ModAlt = 0x0001;
public const uint ModControl = 0x0002;
public const uint ModShift = 0x0004;
public const uint ModWin = 0x0008;
private readonly ConcurrentDictionary<int, Action> _actions = new();
private int _actionId;
private Thread? _thread;
private uint _threadId;
private ManualResetEventSlim? _threadStarted;
private Action? _callback;
private bool _isRegistered;
private uint _currentModifiers;
private uint _currentKey;
private bool _isDisposed;
/// <inheritdoc />
public bool IsSupported => OperatingSystem.IsWindows();
/// <inheritdoc />
public void RegisterHotKey(string modifiers, string key, Action callback)
{
if (!IsSupported) return;
_callback = callback;
_currentModifiers = ParseModifiers(modifiers);
_currentKey = ParseKey(key);
StartThreadIfNeeded();
InvokeOnHotKeyThread(() =>
{
UnregisterHotKeyInternal();
if (_currentKey == 0)
{
return;
}
if (RegisterHotKey(IntPtr.Zero, HotKeyId, _currentModifiers, _currentKey))
{
_isRegistered = true;
}
else
{
Debug.WriteLine("[WindowsGlobalHotKeyService] Failed to register hotkey.");
}
});
}
/// <inheritdoc />
public void UnregisterHotKey()
{
if (!IsSupported) return;
StartThreadIfNeeded();
InvokeOnHotKeyThread(UnregisterHotKeyInternal);
}
/// <inheritdoc />
public void UpdateHotKey(string modifiers, string key)
{
if (_callback == null) return;
RegisterHotKey(modifiers, key, _callback);
}
private void UnregisterHotKeyInternal()
{
if (_isRegistered)
{
UnregisterHotKey(IntPtr.Zero, HotKeyId);
_isRegistered = false;
}
}
private void StartThreadIfNeeded()
{
if (_thread != null) return;
_threadStarted = new ManualResetEventSlim(false);
_thread = new Thread(MessageLoop)
{
IsBackground = true,
Name = "Hua.Todo HotKey Thread"
};
if (OperatingSystem.IsWindows())
{
_thread.SetApartmentState(ApartmentState.STA);
}
_thread.Start();
_threadStarted.Wait();
}
private void MessageLoop()
{
_threadId = GetCurrentThreadId();
_threadStarted?.Set();
while (GetMessage(out var msg, IntPtr.Zero, 0, 0) != 0)
{
if (msg.message == WmHotKey && msg.wParam == (UIntPtr)HotKeyId)
{
try
{
_callback?.Invoke();
}
catch (Exception ex)
{
Debug.WriteLine($"[WindowsGlobalHotKeyService] Hotkey callback failed: {ex}");
}
continue;
}
if (msg.message == WmAppExecute)
{
var id = unchecked((int)msg.wParam);
if (_actions.TryRemove(id, out var action))
{
try
{
action();
}
catch (Exception ex)
{
Debug.WriteLine($"[WindowsGlobalHotKeyService] Action execution failed: {ex}");
}
}
continue;
}
if (msg.message == WmQuit)
{
break;
}
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
_threadStarted?.Dispose();
_threadStarted = null;
}
private void InvokeOnHotKeyThread(Action action)
{
var id = Interlocked.Increment(ref _actionId);
_actions[id] = action;
if (!PostThreadMessage(_threadId, WmAppExecute, (UIntPtr)id, IntPtr.Zero))
{
_actions.TryRemove(id, out _);
}
}
private static uint ParseModifiers(string modifiers)
{
uint mod = 0;
if (string.IsNullOrWhiteSpace(modifiers)) return mod;
var parts = modifiers.Split(',');
foreach (var part in parts)
{
var p = part.Trim();
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= ModControl;
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= ModAlt;
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= ModShift;
if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= ModWin;
}
return mod;
}
private static uint ParseKey(string key)
{
if (string.IsNullOrWhiteSpace(key)) return 0;
if (key.Length == 1)
{
var c = char.ToUpperInvariant(key[0]);
if (c is >= 'A' and <= 'Z') return c;
if (c is >= '0' and <= '9') return c;
}
return 0x58; // Default 'X'
}
/// <inheritdoc />
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
try
{
UnregisterHotKey();
}
catch
{
}
if (_threadId != 0)
{
PostThreadMessage(_threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero);
}
}
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
public IntPtr hwnd;
public uint message;
public UIntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll")]
private static extern sbyte GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref Msg lpMsg);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool PostThreadMessage(uint idThread, uint msg, UIntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
}
@@ -0,0 +1,138 @@
using System;
using System.Runtime.InteropServices;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Windows 全局键盘事件处理器。
/// 用于监听按键并向上层暴露事件(例如 Esc),以保证在 WebView 获得焦点时依旧可触发窗口级交互。
/// </summary>
public sealed class WindowsKeyboardHandler : IDisposable
{
private readonly KeyboardHook _keyboardHook;
private bool _isDisposed;
/// <summary>
/// 当检测到 Esc 键抬起时触发。
/// </summary>
public event EventHandler? EscKeyPressed;
/// <summary>
/// 创建 <see cref="WindowsKeyboardHandler"/>。
/// </summary>
public WindowsKeyboardHandler()
{
_keyboardHook = new KeyboardHook();
_keyboardHook.KeyPressed += OnKeyPressed;
}
/// <summary>
/// 开始监听键盘事件。
/// </summary>
public void Start()
{
_keyboardHook.Hook();
}
private void OnKeyPressed(object? sender, KeyPressedEventArgs e)
{
if (e.Key == 0x1B && !e.IsKeyDown)
{
EscKeyPressed?.Invoke(this, EventArgs.Empty);
}
}
/// <inheritdoc />
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
_keyboardHook.Unhook();
_keyboardHook.KeyPressed -= OnKeyPressed;
}
private sealed class KeyboardHook : IDisposable
{
private const int WhKeyboardLl = 13;
private const int WmKeyDown = 0x0100;
private const int WmKeyUp = 0x0101;
private const int WmSysKeyDown = 0x0104;
private const int WmSysKeyUp = 0x0105;
private readonly LowLevelKeyboardProc _proc;
private IntPtr _hookId = IntPtr.Zero;
private bool _isDisposed;
public event EventHandler<KeyPressedEventArgs>? KeyPressed;
public KeyboardHook()
{
_proc = HookCallback;
}
public void Hook()
{
if (_hookId != IntPtr.Zero) return;
_hookId = SetWindowsHookEx(WhKeyboardLl, _proc, GetModuleHandle(null), 0);
}
public void Unhook()
{
if (_hookId == IntPtr.Zero) return;
UnhookWindowsHookEx(_hookId);
_hookId = IntPtr.Zero;
}
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
var vkCode = Marshal.ReadInt32(lParam);
var isKeyDown = wParam == (IntPtr)WmKeyDown || wParam == (IntPtr)WmSysKeyDown;
var isKeyUp = wParam == (IntPtr)WmKeyUp || wParam == (IntPtr)WmSysKeyUp;
if (isKeyDown || isKeyUp)
{
KeyPressed?.Invoke(this, new KeyPressedEventArgs(vkCode, isKeyDown));
}
}
return CallNextHookEx(_hookId, nCode, wParam, lParam);
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
Unhook();
}
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
}
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
private sealed class KeyPressedEventArgs : EventArgs
{
public int Key { get; }
public bool IsKeyDown { get; }
public KeyPressedEventArgs(int key, bool isKeyDown)
{
Key = key;
IsKeyDown = isKeyDown;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Hua.Todo.Avalonia.ViewModels;
namespace Hua.Todo.Avalonia;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
@@ -0,0 +1,47 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
namespace Hua.Todo.Avalonia.ViewModels;
/// <summary>
/// 应用托盘交互的 ViewModel。
/// 用于为 <see cref="global::Avalonia.Controls.TrayIcon"/> 提供命令与提示文本,并将托盘事件路由到应用层逻辑。
/// </summary>
public sealed partial class AppTrayViewModel : ObservableObject
{
private readonly Action _showMainWindow;
private readonly Action _exitApplication;
/// <summary>
/// 创建 <see cref="AppTrayViewModel"/>。
/// </summary>
/// <param name="trayTooltipText">托盘提示文本(部分平台有长度限制)。</param>
/// <param name="showMainWindow">显示/激活主窗口回调。</param>
/// <param name="exitApplication">退出应用回调(应触发应用生命周期关闭)。</param>
public AppTrayViewModel(string trayTooltipText, Action showMainWindow, Action exitApplication)
{
TrayTooltipText = trayTooltipText;
_showMainWindow = showMainWindow;
_exitApplication = exitApplication;
ShowMainWindowCommand = new RelayCommand(_showMainWindow);
ExitApplicationCommand = new RelayCommand(_exitApplication);
}
/// <summary>
/// 托盘提示文本。
/// </summary>
public string TrayTooltipText { get; }
/// <summary>
/// 显示/激活主窗口命令。
/// </summary>
public IRelayCommand ShowMainWindowCommand { get; }
/// <summary>
/// 退出应用命令。
/// </summary>
public IRelayCommand ExitApplicationCommand { get; }
}
@@ -0,0 +1,6 @@
namespace Hua.Todo.Avalonia.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public string Greeting { get; } = "Welcome to Avalonia!";
}
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Hua.Todo.Avalonia.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
@@ -0,0 +1,21 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
x:Class="Hua.Todo.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Width="450"
Height="640"
Title="待办事项"
WindowStartupLocation="CenterScreen">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<WebView Name="MainWebView" />
</Window>
@@ -0,0 +1,102 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using System;
namespace Hua.Todo.Avalonia.Views;
/// <summary>
/// 应用主窗口。
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
/// </summary>
public partial class MainWindow : Window
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService _webServer;
/// <summary>
/// 创建用于设计器预览的主窗口实例。
/// </summary>
public MainWindow()
{
InitializeComponent();
_appSettings = new AppSettings();
_webServer = new EmbeddedWebServerService(_appSettings);
}
/// <summary>
/// 创建运行时主窗口实例。
/// </summary>
/// <param name="appSettings">应用配置。</param>
/// <param name="webServer">嵌入式 WebServer。</param>
public MainWindow(AppSettings appSettings, IEmbeddedWebServerService webServer)
{
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebView();
}
private void SetupWebView()
{
try
{
MainWebView.Url =
_appSettings.WebServer.IsUsingStatic
? new Uri(_appSettings.WebServer.HostUrl)
: new Uri(_appSettings.WebServer.ForEndUrl);
MainWebView.NavigationCompleted += async (s, e) =>
{
try
{
if (_webServer.IsRunning)
{
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.ExecuteScriptAsync(@"
window.mauiInterop = {
onHotKeyConfigUpdated: null,
openHotKeySettings: function(config) {
const event = new CustomEvent('openHotKeySettings', { detail: config });
window.dispatchEvent(event);
},
updateHotKeyConfig: function(modifiers, key, isEnabled) {
const event = new CustomEvent('updateHotKeyConfig', {
detail: { modifiers, key, isEnabled }
});
window.dispatchEvent(event);
}
};
window.addEventListener('hotKeyConfigChanged', function(e) {
if (window.mauiInterop.onHotKeyConfigUpdated) {
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
}
});
");
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}");
Content = new TextBlock
{
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Hua.Todo.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+14
View File
@@ -0,0 +1,14 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
},
"HotKey": {
"DefaultModifiers": "Alt",
"DefaultKey": "X",
"DefaultIsEnabled": true
}
}
@@ -0,0 +1,32 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 安全策略配置(服务端下发)。
/// </summary>
public class SecurityPolicyEntity
{
/// <summary>
/// 策略唯一标识符。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 策略所属用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 策略所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 是否允许客户端落盘(为 false 时客户端应进入“内存模式”,不产生本地持久化)。
/// </summary>
public bool AllowPersist { get; set; } = true;
/// <summary>
/// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。
/// </summary>
public bool AllowSync { get; set; } = true;
}
+11
View File
@@ -5,6 +5,17 @@ namespace Hua.Todo.Core.Entities;
/// </summary>
public class TaskEntity
{
/// <summary>
/// 任务所属用户 ID。
/// 本地模式下使用固定的“本地用户”ID,以确保本地任务与云端多用户任务隔离。
/// </summary>
public Guid UserId { get; set; } = TodoUserIds.LocalUserId;
/// <summary>
/// 任务所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 任务唯一标识符
/// </summary>
+13
View File
@@ -0,0 +1,13 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 系统内置用户 ID 常量。
/// </summary>
public static class TodoUserIds
{
/// <summary>
/// 本地模式专用的内置用户 ID。
/// </summary>
public static readonly Guid LocalUserId = Guid.Parse("00000000-0000-0000-0000-000000000001");
}
+33
View File
@@ -0,0 +1,33 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 用户实体,用于云同步场景下的用户隔离与权限控制。
/// </summary>
public class UserEntity
{
/// <summary>
/// 用户唯一标识符。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 登录用户名(全局唯一)。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 登录密码哈希(不存储明文)。
/// </summary>
public string PasswordHash { get; set; } = string.Empty;
/// <summary>
/// 用户角色(用于 RBAC 权限映射)。
/// </summary>
public string Role { get; set; } = "user";
/// <summary>
/// 用户任务集合。
/// </summary>
public List<TaskEntity> Tasks { get; set; } = new();
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 用户会话实体(服务端会话令牌)。
/// </summary>
public class UserSessionEntity
{
/// <summary>
/// 会话唯一标识符,同时作为 Bearer Token 值使用。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 会话所属用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 会话所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 会话创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 会话过期时间(UTC)。
/// </summary>
public DateTime ExpiresAtUtc { get; set; }
/// <summary>
/// 二次认证(step-up)有效期截止(UTC);为空表示未进行二次认证。
/// </summary>
public DateTime? StepUpExpiresAtUtc { get; set; }
}
+9 -2
View File
@@ -1,15 +1,20 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.DynamicApi.Swagger;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthorization();
builder.Services.AddSwaggerGen(options =>
{
options.DocumentFilter<DynamicApiSwaggerDocumentFilter>();
});
builder.Services.AddCloudSyncServer();
builder.Services.AddApplicationServices("Data Source=Hua.Todo.db");
@@ -40,7 +45,9 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseCors("AllowAll");
app.UseAuthentication();
app.UseAuthorization();
app.MapCloudSyncEndpoints();
app.UseDynamicApi();
app.Run();
+38 -15
View File
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
@@ -29,7 +30,7 @@
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<!-- Versions -->
<Version>1.1.5</Version>
<Version>1.2.1</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
@@ -54,6 +55,10 @@
<ForceWebBuild>false</ForceWebBuild>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidUseAapt2>True</AndroidUseAapt2>
@@ -95,8 +100,8 @@
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Include="icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<MauiImage Update="Resources\Images\icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\icon.jpg" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
@@ -127,7 +132,7 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
@@ -138,6 +143,10 @@
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And '$(Configuration)' == 'Debug'">
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
</ItemGroup>
@@ -146,13 +155,19 @@
<Folder Include="wwwroot\" />
</ItemGroup>
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoWebDistDir)\index.html'))">
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<!-- Windows embedded server serves static files from AppContext.BaseDirectory\wwwroot. -->
<Content Include="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<!-- Build web assets into MAUI `wwwroot` so both Windows publish and Android packaging can reuse the same folder. -->
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(MSBuildProjectDirectory)\wwwroot\index.html'))">
<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)" />
<Exec Command="npm run build:maui" WorkingDirectory="$(TodoWebDir)" />
</Target>
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoWebDistDir)\index.html')">
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And '$(UseDistSyncToWwwroot)' == 'true' And Exists('$(TodoWebDistDir)\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
@@ -169,15 +184,16 @@
</ItemGroup>
</Target>
<Target Name="CopyTodoWebDistToWindowsWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And Exists('$(TodoWebDistDir)')">
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(MSBuildProjectDirectory)\wwwroot\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
<_MauiWwwrootFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" />
</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" />
<ItemGroup>
<MauiAsset Include="@(_MauiWwwrootFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
</Project>
@@ -189,3 +205,10 @@
+15 -2
View File
@@ -13,13 +13,14 @@ namespace Hua.Todo.Maui;
/// <summary>
/// MAUI 程序启动类
/// </summary>
public static class MauiProgram
public static partial class MauiProgram
{
/// <summary>
/// 创建并配置 MAUI 应用程序
/// </summary>
public static MauiApp CreateMauiApp()
{
ConfigurePlatformWebViewContainer();
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
@@ -71,7 +72,17 @@ public static class MauiProgram
// 注册嵌入式 Web 服务器(平台相关)
#if WINDOWS
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
// Windows 平台下嵌入式 WebServer 的启用策略:
// - 静态托管模式(IsUsingStatic=true):由 MAUI 内置 WebServer 提供 wwwroot 与本地 API。
// - 开发三件套模式(IsUsingStatic=false,前端走 Vite):API 由独立 Host(5173) 提供,避免在 MAUI 内启动 WebServer 导致注入覆盖前端代理配置。
if (appSettings.WebServer.IsUsingStatic)
{
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
}
else
{
builder.Services.AddSingleton<IEmbeddedWebServerService, NoopEmbeddedWebServerService>();
}
#elif ANDROID
builder.Services.AddSingleton<IEmbeddedWebServerService, MobileEmbeddedWebServerService>();
#else
@@ -101,6 +112,8 @@ public static class MauiProgram
return app;
}
static partial void ConfigurePlatformWebViewContainer();
/// <summary>
/// 从 appsettings.json 加载配置
/// </summary>
@@ -1,4 +1,5 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.ApplicationModel;
namespace Hua.Todo.Maui.Views
{
@@ -30,5 +31,53 @@ namespace Hua.Todo.Maui.Views
var windowService = new Platforms.Windows.WindowsWindowService();
windowService.MinimizeWindow(window);
}
partial void PlatformPrepareWebViewContainer()
{
if (Platforms.Windows.WebView2RuntimeDetector.IsRuntimeInstalled(out _))
{
return;
}
_isWebViewContainerReady = false;
var downloadUrl = "https://developer.microsoft.com/microsoft-edge/webview2/";
var content = new VerticalStackLayout
{
Padding = new Thickness(20),
Spacing = 12,
Children =
{
new Label
{
Text = "检测到系统未安装 WebView2 Runtime,无法加载主界面。",
FontSize = 16
},
new Label
{
Text = "请安装 Microsoft Edge WebView2 RuntimeEvergreen),安装完成后重新打开应用。",
Opacity = 0.85
},
new Button
{
Text = "打开下载页面",
Command = new Command(async () =>
{
await Launcher.Default.OpenAsync(downloadUrl);
})
},
new Button
{
Text = "退出应用",
Command = new Command(() =>
{
Microsoft.Maui.Controls.Application.Current?.Quit();
})
}
}
};
Content = new ScrollView { Content = content };
}
}
}
@@ -0,0 +1,99 @@
using System.Reflection;
namespace Hua.Todo.Maui.Platforms.Windows;
internal static class WebView2RuntimeDetector
{
/// <summary>
/// 判断当前 Windows 系统是否可用 WebView2 Runtime。
/// 优先通过已知的 Evergreen 安装目录探测(避免因托管程序集缺失/裁剪导致误判),
/// 其次再尝试通过 WebView2 SDK 的 <c>CoreWebView2Environment.GetAvailableBrowserVersionString</c> 获取版本。
/// </summary>
/// <param name="version">检测到的运行时版本(若可用)。</param>
/// <returns>若系统存在可用的 WebView2 Runtime,则返回 <c>true</c>;否则返回 <c>false</c>。</returns>
internal static bool IsRuntimeInstalled(out string? version)
{
version = null;
try
{
version = TryGetEvergreenVersionFromKnownLocations();
if (!string.IsNullOrWhiteSpace(version))
{
return true;
}
var type = Type.GetType("Microsoft.Web.WebView2.Core.CoreWebView2Environment, Microsoft.Web.WebView2.Core");
if (type == null)
{
return false;
}
version = TryInvokeVersionGetter(type);
return !string.IsNullOrWhiteSpace(version);
}
catch
{
return false;
}
}
private static string? TryGetEvergreenVersionFromKnownLocations()
{
var candidates = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "EdgeWebView", "Application"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft", "EdgeWebView", "Application"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "EdgeWebView", "Application"),
};
Version? best = null;
foreach (var baseDir in candidates)
{
if (string.IsNullOrWhiteSpace(baseDir) || !Directory.Exists(baseDir))
{
continue;
}
foreach (var dir in Directory.EnumerateDirectories(baseDir))
{
var name = Path.GetFileName(dir);
if (!Version.TryParse(name, out var v))
{
continue;
}
if (!File.Exists(Path.Combine(dir, "msedgewebview2.exe")))
{
continue;
}
if (best == null || v > best)
{
best = v;
}
}
}
return best?.ToString();
}
private static string? TryInvokeVersionGetter(Type environmentType)
{
var noArg = environmentType.GetMethod("GetAvailableBrowserVersionString", BindingFlags.Public | BindingFlags.Static, Type.DefaultBinder, Type.EmptyTypes, null);
if (noArg != null)
{
return noArg.Invoke(null, null) as string;
}
var oneArg = environmentType.GetMethod("GetAvailableBrowserVersionString", BindingFlags.Public | BindingFlags.Static, Type.DefaultBinder, new[] { typeof(string) }, null);
if (oneArg != null)
{
return oneArg.Invoke(null, new object?[] { null }) as string;
}
return null;
}
}
+23 -2
View File
@@ -62,6 +62,25 @@ cd Hua.Todo.Maui
dotnet build -f net10.0-windows10.0.19041.0
dotnet run -f net10.0-windows10.0.19041.0
```
Windows Debug 编译下,MAUI 内嵌 WebServer 会提供接口文档:
- Swagger UI`{HostUrl}/swagger`
- OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`
其中 `{HostUrl}` 来自 `appsettings.json: WebServer.HostUrl`(默认 `http://localhost:5057`)。
按默认配置,对应地址为:
- Swagger UI`http://localhost:5057/swagger`
- OpenAPI JSON`http://localhost:5057/swagger/v1/swagger.json`
#### Windows(三件套热更新:MAUI + Vite + Host
该模式用于开发阶段获得最佳热更新体验:
- `Hua.Todo.Host`:提供 API`http://localhost:5173`
- `Hua.Todo.Web`Vite dev server`http://localhost:5174`),并将 `/api` 代理到 5173
- `Hua.Todo.Maui`WebView 加载 5174
在该模式下,Swagger UI 地址为:`http://localhost:5173/swagger`(仅 `ASPNETCORE_ENVIRONMENT=Development` 时启用)。
```powershell
.\start-dev.ps1
```
然后在 Visual Studio 中启动 `Hua.Todo.Maui`F5)。
#### macOS
```bash
@@ -134,7 +153,9 @@ dotnet run -f net10.0-android
1. **macOS 权限**: 首次运行时需要在系统设置中授予辅助功能权限
2. **Windows UAC**: 某些情况下可能需要管理员权限
3. **移动端限制**: 移动端不支持真正的全局快捷键,使用通知快捷方式替代
4. **WebView**: 确保 Hua.Todo.Api 服务`http://localhost:5173` 运行
4. **WebView(开发)**: 三件套模式下需要确保 `Hua.Todo.Host` `http://localhost:5173` 运行,同时 `Hua.Todo.Web``http://localhost:5174` 运行
5. **Windows WebView2 数据目录**: WebView2 的缓存/存储写入 `%LocalAppData%\Hua.Todo\WebView2`,避免在安装目录生成 `*.WebView2` 文件夹
6. **Windows WebView2 Runtime**: 依赖系统安装的 Microsoft Edge WebView2 Runtime;若缺失应用会提示下载安装
## 后续计划
@@ -146,4 +167,4 @@ dotnet run -f net10.0-android
## 许可证
AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN))
AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN))

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ namespace Hua.Todo.Maui.Services;
public static class AppMetadata
{
private const string AppNameText = "\u5F85\u529E\u4E8B\u9879";
private const string AppNameText = "Hua.Todo";
public static string AppName => AppNameText;
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting;
using System.Text.Json;
using Hua.Todo.Application;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.DynamicApi.Swagger;
using Hua.Todo.Maui.Models;
using AppSettings = Hua.Todo.Maui.Models.AppSettings;
@@ -65,6 +66,12 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
});
builder.Services.AddEndpointsApiExplorer();
#if DEBUG
builder.Services.AddSwaggerGen(options =>
{
options.DocumentFilter<DynamicApiSwaggerDocumentFilter>();
});
#endif
// 注册应用逻辑服务
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
@@ -82,6 +89,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
var app = builder.Build();
#if DEBUG
app.UseSwagger();
app.UseSwaggerUI();
#endif
// 如果配置为使用静态文件(前端托管),则配置静态文件服务
if (_appSettings.WebServer.IsUsingStatic)
{
@@ -135,7 +147,18 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
if (context.Request.Path.HasValue)
{
var path = context.Request.Path.Value;
if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
// Swagger 仅在 DEBUG 下启用;Release 下不应把 /swagger 当作“后端专用路径”排除,
// 否则访问 /swagger 会直接 404 而不会回落到 SPA/index.html)。
#if DEBUG
var isSwaggerPath = path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase);
#else
var isSwaggerPath = false;
#endif
if (path != "/"
&& !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase)
&& !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase)
&& !isSwaggerPath)
{
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
+9
View File
@@ -12,6 +12,7 @@ namespace Hua.Todo.Maui.Views
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService? _webServer;
private bool _isWebViewContainerReady = true;
/// <summary>
/// 创建 <see cref="MainPage"/>。
@@ -24,6 +25,12 @@ namespace Hua.Todo.Maui.Views
_appSettings = appSettings;
_webServer = webServer;
PlatformPrepareWebViewContainer();
if (!_isWebViewContainerReady)
{
return;
}
SetupWebViewSource();
SetupWebViewCommunication();
SetupKeyboardHandler();
@@ -141,10 +148,12 @@ namespace Hua.Todo.Maui.Views
/// 平台特定的键盘处理器初始化。
/// </summary>
partial void PlatformSetupKeyboardHandler();
/// <summary>
/// 平台特定的 Esc 键处理逻辑。
/// </summary>
/// <param name="window">当前窗口。</param>
partial void PlatformOnEscKeyPressed(Window window);
partial void PlatformPrepareWebViewContainer();
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"IsUsingStatic": true,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
"ForEndUrl": "http://localhost:5057"
},
"Development": {
},
+11 -4
View File
@@ -1,8 +1,8 @@
#define MyAppName "Hua.Todo"
#define MyAppVersion "1.1.4"
#define MyAppVersion "1.2.0"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.exe"
#define MyAppExeName "Hua.Todo.Maui.exe"
[Setup]
; 1. 改用更快压缩(优先速度)
@@ -34,6 +34,7 @@ PrivilegesRequired=lowest
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
SetupIconFile=icon.ico
UninstallDisplayIcon={app}\icon.ico
SolidCompression=no
WizardStyle=modern
@@ -47,9 +48,15 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
Source: "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 注意: 请勿在任何共享系统文件上使用“Flags: ignoreversion”
[InstallDelete]
Type: filesandordirs; Name: "{app}\{#MyAppExeName}.WebView2"
[UninstallDelete]
Type: filesandordirs; Name: "{app}\{#MyAppExeName}.WebView2"
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\icon.ico"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+2 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Hua.Todo-web",
"name": "Hua.Todo-Web",
"private": true,
"version": "0.0.0",
"license": "AGPL-3.0",
@@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build:maui": "vue-tsc -b && vite build --mode maui",
"preview": "vite preview"
},
"dependencies": {
+2
View File
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue';
import TaskList from './components/TaskList.vue';
import HotKeySettingsDialog from './components/HotKeySettingsDialog.vue';
import CloudSyncSettingsDialog from './components/CloudSyncSettingsDialog.vue';
const toastMessage = ref('');
const toastType = ref<'error' | 'success'>('error');
@@ -26,6 +27,7 @@ onMounted(() => {
<div class="app">
<TaskList />
<HotKeySettingsDialog />
<CloudSyncSettingsDialog />
<!-- Global Toast Notification -->
<Transition name="toast">
+7
View File
@@ -48,6 +48,13 @@ apiClient.interceptors.response.use(
console.error('API Error:', error);
let errorMessage = '网络错误,请稍后再试';
const status = error?.response?.status as number | undefined;
if (status === 502 || status === 503 || status === 504) {
errorMessage = '后端服务不可达:请确认 Hua.Todo.Host 已启动(http://localhost:5173';
showNotification(errorMessage, 'error');
return Promise.reject(error);
}
if (error.response && error.response.data) {
const data = error.response.data;
const errors = data.errors || data.Errors;
+86
View File
@@ -0,0 +1,86 @@
import axios from 'axios';
import CloudSyncStorage from '../services/cloudSyncStorage';
const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
let lastOpenCloudSyncSettingsAt = 0;
const requestCloudSyncReLogin = (): void => {
const now = Date.now();
if (now - lastOpenCloudSyncSettingsAt < 1500) return;
lastOpenCloudSyncSettingsAt = now;
window.dispatchEvent(new CustomEvent('openCloudSyncSettings'));
};
/**
* 云同步专用 HTTP Client。
*
* 注意:本项目现有 `client.ts` 的 baseURL 固定为 `window.__API_BASE_URL__`(通常已包含 `/api`),
* 但云同步服务端端点位于根路径(`/auth/*`、`/tasks/*` 等)。
* 因此云同步需要独立的 baseURL(用户配置的服务端地址)。
*/
const cloudClient = axios.create({
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
cloudClient.interceptors.request.use((config) => {
const { serverUrl } = CloudSyncStorage.loadSettings();
if (serverUrl) {
config.baseURL = serverUrl;
}
const session = CloudSyncStorage.loadSession();
if (session?.accessToken) {
config.headers = config.headers ?? {};
(config.headers as any).Authorization = `Bearer ${session.accessToken}`;
}
return config;
});
cloudClient.interceptors.response.use(
(response) => response,
(error) => {
let errorMessage = '网络错误,请稍后再试';
const status = error?.response?.status as number | undefined;
if (status === 401 || status === 403) {
CloudSyncStorage.clearSession();
requestCloudSyncReLogin();
errorMessage = status === 401 ? '云同步会话已过期,请重新登录' : '云同步权限不足,请重新登录或联系管理员';
showNotification(errorMessage, 'error');
return Promise.reject(error);
}
if (error?.response?.data) {
const data = error.response.data;
if (typeof data === 'string' && data.trim()) {
errorMessage = data;
} else if (data && typeof data === 'object') {
const message = (data.message ?? data.Message) as unknown;
const errors = (data.errors ?? data.Errors) as unknown;
if (Array.isArray(errors) && errors.length > 0) {
errorMessage = errors.join('\n');
} else if (typeof message === 'string' && message.trim()) {
errorMessage = message;
}
}
} else if (typeof error?.message === 'string' && error.message.trim()) {
errorMessage = error.message;
}
showNotification(errorMessage, 'error');
return Promise.reject(error);
}
);
export default cloudClient;
+98
View File
@@ -0,0 +1,98 @@
import cloudClient from './cloudClient';
import CloudSyncStorage from '../services/cloudSyncStorage';
import type { Task } from '../types/task';
export interface CloudLoginRequest {
userName: string;
password: string;
}
export interface CloudLoginResponse {
accessToken: string;
expiresAtUtc: string;
userId: string;
role: string;
permissions: string[];
}
export interface CloudTaskItem {
id: number;
title: string;
priority: 0 | 1 | 2;
isCompleted: boolean;
createdAtUtc: string;
updatedAtUtc: string;
parentTaskId?: number | null;
}
/**
* 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。
*/
export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
const map = new Map<number, Task>();
for (const item of items) {
map.set(item.id, {
id: item.id,
title: item.title ?? '',
priority: item.priority ?? 1,
isCompleted: Boolean(item.isCompleted),
createdAt: item.createdAtUtc,
updatedAt: item.updatedAtUtc,
parentTaskId: item.parentTaskId ?? undefined,
subTasks: [],
});
}
const roots: Task[] = [];
for (const task of map.values()) {
const parentId = task.parentTaskId;
if (typeof parentId === 'number' && map.has(parentId)) {
map.get(parentId)!.subTasks.push(task);
} else {
roots.push(task);
}
}
return roots;
};
/**
* 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。
*/
export const cloudSyncApi = {
/**
* 登录云同步服务端并保存会话(默认存入 sessionStorage)。
*/
async login(request: CloudLoginRequest): Promise<CloudLoginResponse> {
const response = await cloudClient.post<CloudLoginResponse>('/auth/login', request);
const session = response.data;
CloudSyncStorage.saveSession({
accessToken: session.accessToken,
expiresAtUtc: session.expiresAtUtc,
userId: session.userId,
role: session.role,
permissions: Array.isArray(session.permissions) ? session.permissions : [],
});
return session;
},
/**
* 清空云同步会话(登出)。
*/
logout(): void {
CloudSyncStorage.clearSession();
},
/**
* 获取云端任务全量,并转换为前端树结构。
*/
async getTasks(): Promise<Task[]> {
const response = await cloudClient.get<CloudTaskItem[]>('/tasks/');
const items = Array.isArray(response.data) ? response.data : [];
return buildTaskTreeFromCloudItems(items);
},
};
export default cloudSyncApi;
@@ -0,0 +1,534 @@
<template>
<div v-if="isOpen" class="overlay" @click.self="close">
<div class="dialog">
<div class="dialog-header">
<h2> 云同步设置</h2>
<button class="close-button" @click="close">&times;</button>
</div>
<div class="dialog-body">
<div class="section">
<div class="section-title">同步开关</div>
<label class="checkbox-row">
<input type="checkbox" v-model="localEnabled" @change="onToggleEnabled" />
<span>启用云同步v1.2.0仅支持登录后拉取任务</span>
</label>
<div class="hint">
启用后主界面将以云端任务为准并进入只读展示本地编辑/新增将在后续版本补齐
</div>
</div>
<div class="section">
<div class="section-title">服务端地址</div>
<div class="form-row">
<input
v-model="serverUrlInput"
class="text-input"
type="text"
placeholder="例如:https://example.com"
autocomplete="off"
/>
<button class="btn btn-secondary" :disabled="isProbing" @click="saveServerUrl">
{{ isProbing ? '探测中...' : '保存并探测' }}
</button>
</div>
<div v-if="serverUrlSaved" class="hint">
已保存<span class="mono">{{ serverUrlSaved }}</span>
</div>
<div v-if="probeResult" class="probe" :class="probeResult.type">
<div class="probe-title">{{ probeResult.title }}</div>
<div class="probe-desc">{{ probeResult.desc }}</div>
</div>
</div>
<div class="section">
<div class="section-title">登录</div>
<div v-if="isLoggedIn" class="logged-in">
<div class="hint">
已登录<span class="mono">{{ sessionSummary }}</span>
</div>
<div class="button-row">
<button class="btn btn-secondary" @click="logout">登出</button>
<button class="btn btn-primary" :disabled="!localEnabled" @click="pullTasks">
拉取任务
</button>
</div>
</div>
<div v-else class="login-form">
<div class="form-row">
<input
v-model="userName"
class="text-input"
type="text"
placeholder="用户名"
autocomplete="username"
/>
<input
v-model="password"
class="text-input"
type="password"
placeholder="密码"
autocomplete="current-password"
/>
<button class="btn btn-primary" :disabled="!canLogin" @click="login">
{{ isLoggingIn ? '登录中...' : '登录' }}
</button>
</div>
<div class="hint">
说明服务端使用 Bearer 会话AccessToken=SessionId会话默认仅保存在本次运行sessionStorage
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="close">关闭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import CloudSyncStorage from '../services/cloudSyncStorage';
import cloudSyncApi from '../api/cloudSync';
type ProbeType = 'success' | 'warn' | 'error';
interface ProbeResult {
type: ProbeType;
title: string;
desc: string;
}
const isOpen = ref(false);
const localEnabled = ref(false);
const serverUrlInput = ref('');
const serverUrlSaved = ref('');
const isProbing = ref(false);
const probeResult = ref<ProbeResult | null>(null);
const userName = ref('');
const password = ref('');
const isLoggingIn = ref(false);
const loadLocalState = () => {
const settings = CloudSyncStorage.loadSettings();
localEnabled.value = settings.enabled;
serverUrlSaved.value = settings.serverUrl;
serverUrlInput.value = settings.serverUrl;
};
const isLoggedIn = computed(() => Boolean(CloudSyncStorage.loadSession()?.accessToken));
const sessionSummary = computed(() => {
const s = CloudSyncStorage.loadSession();
if (!s) return '';
const shortUserId = s.userId ? s.userId.slice(0, 8) : '';
return `${s.role || 'User'}@${shortUserId}`;
});
const canLogin = computed(() => {
if (!localEnabled.value) return false;
if (!serverUrlSaved.value) return false;
if (!userName.value.trim() || !password.value) return false;
return !isLoggingIn.value;
});
const showToast = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
const normalizeServerUrl = (raw: string): { ok: boolean; normalized?: string; message?: string; warn?: string } => {
const trimmed = raw.trim();
if (!trimmed) return { ok: false, message: '请输入服务端地址' };
let url: URL;
try {
url = new URL(trimmed);
} catch {
return { ok: false, message: '地址格式不正确,请包含协议(http:// 或 https://' };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { ok: false, message: '仅支持 http:// 或 https:// 地址' };
}
if (!url.hostname) {
return { ok: false, message: '地址缺少主机名(host' };
}
const origin = url.origin;
const pathWarn = url.pathname && url.pathname !== '/' ? '已忽略地址中的路径部分,仅保留 origin。' : undefined;
return { ok: true, normalized: origin.replace(/\/+$/, ''), warn: pathWarn };
};
const probeReachability = async (serverUrl: string): Promise<ProbeResult> => {
const u = new URL(serverUrl);
const isHttps = u.protocol === 'https:';
const loginUrl = `${serverUrl}/auth/login`;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(loginUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userName: '__probe__', password: '__probe__' }),
signal: controller.signal,
});
if (!isHttps) {
return {
type: 'warn',
title: '可达(存在风险)',
desc: '服务端可访问,但当前为 HTTP(非加密)。建议使用 HTTPS。',
};
}
return {
type: 'success',
title: '可达',
desc: `已探测到服务端响应(HTTP ${res.status})。可继续登录。`,
};
} catch {
if (!isHttps) {
return {
type: 'warn',
title: '存在风险',
desc: '当前为 HTTP 且探测失败。请检查地址是否可达,或改用 HTTPS。',
};
}
return {
type: 'warn',
title: '存在风险',
desc: '探测失败(可能是网络不可达 / 证书异常 / 跨域被阻止)。仍可保存地址并稍后重试登录。',
};
} finally {
window.clearTimeout(timeout);
}
};
const emitCloudSyncStateChanged = () => {
const settings = CloudSyncStorage.loadSettings();
const session = CloudSyncStorage.loadSession();
const event = new CustomEvent('cloudSyncStateChanged', {
detail: {
enabled: settings.enabled,
serverUrl: settings.serverUrl,
isLoggedIn: Boolean(session?.accessToken),
},
});
window.dispatchEvent(event);
};
const onToggleEnabled = () => {
if (localEnabled.value) {
if (!serverUrlSaved.value) {
localEnabled.value = false;
showToast('启用云同步前请先配置并保存服务端地址', 'error');
return;
}
}
CloudSyncStorage.saveSettings({
serverUrl: serverUrlSaved.value,
enabled: localEnabled.value,
});
emitCloudSyncStateChanged();
};
const saveServerUrl = async () => {
const parsed = normalizeServerUrl(serverUrlInput.value);
if (!parsed.ok || !parsed.normalized) {
showToast(parsed.message || '地址不合法', 'error');
return;
}
serverUrlSaved.value = parsed.normalized;
CloudSyncStorage.saveSettings({
serverUrl: serverUrlSaved.value,
enabled: localEnabled.value,
});
if (parsed.warn) {
showToast(parsed.warn, 'success');
} else {
showToast('地址已保存', 'success');
}
isProbing.value = true;
try {
probeResult.value = await probeReachability(serverUrlSaved.value);
} finally {
isProbing.value = false;
}
emitCloudSyncStateChanged();
};
const login = async () => {
if (!canLogin.value) return;
isLoggingIn.value = true;
try {
await cloudSyncApi.login({
userName: userName.value.trim(),
password: password.value,
});
showToast('登录成功', 'success');
emitCloudSyncStateChanged();
await pullTasks();
} finally {
isLoggingIn.value = false;
}
};
const logout = () => {
cloudSyncApi.logout();
showToast('已登出', 'success');
emitCloudSyncStateChanged();
};
const pullTasks = async () => {
const event = new CustomEvent('cloudSyncPullTasksRequested');
window.dispatchEvent(event);
};
function handleOpenSettings() {
loadLocalState();
probeResult.value = null;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
onMounted(() => {
window.addEventListener('openCloudSyncSettings', handleOpenSettings);
});
</script>
<style scoped>
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 92%;
max-width: 720px;
overflow: hidden;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
.dialog-header h2 {
margin: 0;
font-size: 18px;
font-weight: 650;
color: #111827;
}
.close-button {
background: none;
border: none;
font-size: 28px;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: background 0.2s;
}
.close-button:hover {
background: #f3f4f6;
color: #111827;
}
.dialog-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
background: #ffffff;
}
.section-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: #6b7280;
text-transform: uppercase;
margin-bottom: 10px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
user-select: none;
}
.checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #3b82f6;
}
.hint {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #6b7280;
}
.mono {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.form-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.text-input {
flex: 1;
min-width: 240px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
transition: border-color 0.2s, box-shadow 0.2s;
}
.text-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.probe {
margin-top: 10px;
border-radius: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.probe.success {
border-color: rgba(34, 197, 94, 0.25);
background: rgba(34, 197, 94, 0.08);
}
.probe.warn {
border-color: rgba(245, 158, 11, 0.25);
background: rgba(245, 158, 11, 0.08);
}
.probe.error {
border-color: rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.08);
}
.probe-title {
font-size: 13px;
font-weight: 650;
color: #111827;
}
.probe-desc {
font-size: 12px;
color: #374151;
margin-top: 4px;
line-height: 1.4;
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 14px 20px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.btn {
padding: 10px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:enabled {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:enabled {
background: #f3f4f6;
border-color: #9ca3af;
}
</style>
+35 -3
View File
@@ -4,13 +4,14 @@
<input
type="checkbox"
:checked="task.isCompleted"
:disabled="readOnly"
@change="toggleComplete"
class="task-checkbox"
/>
<button v-if="task.subTasks && task.subTasks.length > 0" class="task-expand" @click.stop="toggleExpand" :title="isExpanded ? '收起' : '展开'">
<span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span>
</button>
<button @click="startAddSubTask" class="btn-add-subtask-icon" title="添加子任务">
<button v-if="!readOnly" @click="startAddSubTask" class="btn-add-subtask-icon" title="添加子任务">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
@@ -24,13 +25,13 @@
</div>
</div>
<div class="task-actions">
<button @click="openEditDialog" class="btn-edit-icon" title="编辑">
<button v-if="!readOnly" @click="openEditDialog" class="btn-edit-icon" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20h9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z" />
</svg>
</button>
<button @click="deleteTask" class="btn-delete-icon" title="删除">
<button v-if="!readOnly" @click="deleteTask" class="btn-delete-icon" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -70,6 +71,7 @@
v-for="subTask in task.subTasks"
:key="subTask.id"
:task="subTask"
:readOnly="readOnly"
@updated="handleSubTaskUpdated"
@deleted="handleSubTaskDeleted"
@subtask-created="$emit('subtask-created', $event)"
@@ -92,9 +94,11 @@ import type { Task } from '../types/task';
interface Props {
task: Task;
readOnly?: boolean;
}
const props = defineProps<Props>();
const readOnly = computed(() => props.readOnly === true);
const emit = defineEmits<{
(e: 'updated', task: Task): void;
@@ -110,6 +114,10 @@ const newSubTaskPriority = ref<0 | 1 | 2>(1);
const subTaskInput = ref<HTMLInputElement | null>(null);
const startAddSubTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
showAddSubTask.value = true;
await nextTick();
if (subTaskInput.value) {
@@ -152,6 +160,10 @@ const toggleExpand = () => {
};
const toggleComplete = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
try {
const response = await taskApi.toggleComplete(props.task.id);
if (response.success && response.data) {
@@ -163,6 +175,10 @@ const toggleComplete = async () => {
};
const deleteTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
try {
const response = await taskApi.deleteTask(props.task.id);
if (response.success) {
@@ -174,6 +190,10 @@ const deleteTask = async () => {
};
const createSubTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
if (!newSubTaskTitle.value.trim()) {
alert('请输入子任务标题');
return;
@@ -215,12 +235,24 @@ const handleSubTaskDeleted = (subTaskId: number) => {
};
const openEditDialog = () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
showEditDialog.value = true;
};
const handleTaskSaved = (updatedTask: Task) => {
emit('updated', updatedTask);
};
const notifyReadOnly = () => {
if ((window as any).showToast) {
(window as any).showToast('云同步模式为只读展示,编辑/新增将在后续版本补齐', 'error');
} else {
window.alert('云同步模式为只读展示,编辑/新增将在后续版本补齐');
}
};
</script>
<style scoped>
+262 -11
View File
@@ -31,6 +31,29 @@
<path d="M11 20h2V9h3l-4-4-4 4h3v11z" />
</svg>
</button>
<div class="search-box">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="搜索任务..."
autocomplete="off"
@keydown="handleSearchKeydown"
/>
<button
v-if="searchQuery.trim().length > 0"
type="button"
class="search-clear-btn"
title="清空搜索"
@click="clearSearch"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<label class="checkbox-wrapper">
<input
type="checkbox"
@@ -45,6 +68,12 @@
</svg>
<span>快捷键</span>
</button>
<button class="cloud-sync-btn" @click="openCloudSyncSettings" title="云同步设置">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 18a4 4 0 1 1 1.1-7.85A6 6 0 0 1 20 12.5 3.5 3.5 0 0 1 18.5 19H7z" />
</svg>
<span>云同步</span>
</button>
</div>
</div>
<div class="task-info">
@@ -52,9 +81,12 @@
<span v-if="lastSyncTime > 0" class="info-item">
<span>更新{{ formatSyncTime(lastSyncTime) }}</span>
</span>
<span v-if="cloudStatusLabel" class="info-item">
<span>{{ cloudStatusLabel }}</span>
</span>
</div>
</div>
<div class="task-form">
<div v-if="!isReadOnly" class="task-form">
<input
v-model="newTaskTitle"
type="text"
@@ -74,8 +106,13 @@
</svg>
</button>
</div>
<div v-else class="cloud-readonly-banner">
云同步模式只读展示v1.2.0编辑/新增将在后续版本补齐
</div>
<div v-if="filteredTasks.length === 0" class="empty">暂无任务</div>
<div v-if="filteredTasks.length === 0" class="empty">
{{ searchQuery.trim().length > 0 ? '无匹配任务' : '暂无任务' }}
</div>
<div v-else class="tasks-shell">
<div class="tasks-scroll">
@@ -83,6 +120,7 @@
v-for="task in filteredTasks"
:key="task.id"
:task="task"
:readOnly="isReadOnly"
@updated="handleTaskUpdated"
@deleted="handleTaskDeleted"
@subtask-created="handleSubTaskCreated"
@@ -95,8 +133,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { taskApi } from '../api/tasks';
import cloudSyncApi from '../api/cloudSync';
import TaskItem from './TaskItem.vue';
import LocalStorageService from '../services/localStorageService';
import CloudSyncStorage from '../services/cloudSyncStorage';
import type { Task } from '../types/task';
type SortByType = 'createdAt' | 'completedAt' | 'priority';
@@ -111,6 +151,10 @@ const sortOrder = ref<SortOrderType>('desc');
const isOnline = ref(LocalStorageService.isOnline());
const lastSyncTime = ref<number>(0);
const pendingChanges = ref<number>(0);
const searchQuery = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null);
const isCloudSyncEnabled = ref(false);
const isCloudLoggedIn = ref(false);
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
@@ -139,17 +183,75 @@ const formatSyncTime = (timestamp: number): string => {
}
};
const clearSearch = () => {
searchQuery.value = '';
searchInputRef.value?.focus();
};
const handleSearchKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (searchQuery.value.trim().length > 0) {
event.preventDefault();
clearSearch();
}
return;
}
if (event.key === 'Enter') {
(event.target as HTMLInputElement | null)?.blur?.();
}
};
const normalizeSearchQuery = (query: string): string => query.trim().toLocaleLowerCase();
/**
* 搜索采用“命中即显示(含上下文)”策略:
* - 节点标题命中:保留该节点与其(已通过完成状态过滤后的)完整子树,便于理解层级上下文
* - 子任务命中:保留祖先链,但仅保留命中的分支,减少无关节点干扰
* - 英文大小写不敏感:通过 toLocaleLowerCase 归一化后做 includes 匹配
*/
const filterTasksBySearch = (tasks: Task[], normalizedQuery: string): Task[] => {
return tasks
.map(task => {
const title = (task.title ?? '').toLocaleLowerCase();
const isSelfMatched = title.includes(normalizedQuery);
const hasSubTasks = task.subTasks && task.subTasks.length > 0;
const filteredSubTasks = hasSubTasks ? filterTasksBySearch(task.subTasks, normalizedQuery) : [];
if (!isSelfMatched && filteredSubTasks.length === 0) {
return null;
}
return {
...task,
subTasks: isSelfMatched ? task.subTasks : filteredSubTasks
} satisfies Task;
})
.filter((task): task is Task => task !== null);
};
const filteredTasks = computed(() => {
const rootTasks = tasks.value.filter(t => !t.parentTaskId);
if (showCompleted.value) {
// 当显示已完成时,返回所有根任务(不进行过滤,保持原始树结构)
return sortTasks(rootTasks);
} else {
// 当隐藏已完成时,递归过滤掉已完成的任务
const activeTasks = filterTasksByCompletion(rootTasks, false);
return sortTasks(activeTasks);
}
const completionFilteredRootTasks = showCompleted.value
? rootTasks
: filterTasksByCompletion(rootTasks, false);
const normalizedQuery = normalizeSearchQuery(searchQuery.value);
const searchFilteredRootTasks = normalizedQuery.length > 0
? filterTasksBySearch(completionFilteredRootTasks, normalizedQuery)
: completionFilteredRootTasks;
return sortTasks(searchFilteredRootTasks);
});
const isReadOnly = computed(() => isCloudSyncEnabled.value && isCloudLoggedIn.value);
const cloudStatusLabel = computed(() => {
if (!isCloudSyncEnabled.value) return '';
if (!CloudSyncStorage.loadSettings().serverUrl) return '云同步:未配置';
if (!isCloudLoggedIn.value) return '云同步:未登录';
return '云同步:已登录';
});
const sortTasks = (tasks: Task[]): Task[] => {
@@ -227,6 +329,17 @@ const filterTasksByCompletion = (tasks: Task[], isCompleted: boolean): Task[] =>
const loadTasks = async () => {
try {
if (isReadOnly.value) {
const cloudTasks = await cloudSyncApi.getTasks();
tasks.value = cloudTasks;
lastSyncTime.value = Date.now();
const syncStatus = LocalStorageService.loadSyncStatus();
syncStatus.lastSyncTime = lastSyncTime.value;
LocalStorageService.saveSyncStatus(syncStatus);
pendingChanges.value = syncStatus.pendingChanges;
return;
}
const response = await taskApi.getTasks();
if (response.success && response.data) {
tasks.value = response.data;
@@ -334,16 +447,42 @@ const openHotKeySettings = () => {
window.dispatchEvent(event);
};
const openCloudSyncSettings = () => {
window.dispatchEvent(new CustomEvent('openCloudSyncSettings'));
};
const refreshCloudModeState = () => {
const settings = CloudSyncStorage.loadSettings();
const session = CloudSyncStorage.loadSession();
isCloudSyncEnabled.value = settings.enabled;
isCloudLoggedIn.value = Boolean(session?.accessToken);
};
const handleCloudSyncStateChanged = () => {
refreshCloudModeState();
loadTasks();
};
const handleCloudSyncPullTasksRequested = () => {
refreshCloudModeState();
loadTasks();
};
onMounted(() => {
refreshCloudModeState();
loadTasks();
handleOnlineStatus();
window.addEventListener('online', handleOnlineStatus);
window.addEventListener('offline', handleOnlineStatus);
window.addEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.addEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener);
});
onUnmounted(() => {
window.removeEventListener('online', handleOnlineStatus);
window.removeEventListener('offline', handleOnlineStatus);
window.removeEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.removeEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener);
});
</script>
@@ -583,6 +722,61 @@ onUnmounted(() => {
flex-wrap: wrap;
}
.search-box {
display: inline-flex;
align-items: center;
height: var(--header-chip-height);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
transition: all 0.3s ease;
overflow: hidden;
}
.search-box:focus-within {
border-color: #6366f1;
background: white;
}
.search-input {
height: 100%;
border: none;
outline: none;
background: transparent;
padding: 0 var(--header-chip-pad-x);
font-size: var(--header-chip-font-size);
color: #475569;
width: 160px;
min-width: 120px;
}
.search-input::placeholder {
color: #94a3b8;
}
.search-clear-btn {
height: 100%;
width: var(--header-chip-height);
padding: 0;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.search-clear-btn:hover {
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
}
.search-clear-btn:active {
transform: translateY(0.5px);
}
.sort-select {
height: var(--header-chip-height);
padding: 0 calc(var(--header-chip-pad-x) + 14px) 0 var(--header-chip-pad-x);
@@ -667,6 +861,53 @@ onUnmounted(() => {
font-size: inherit;
}
.cloud-sync-btn {
display: flex;
align-items: center;
gap: 4px;
height: var(--header-chip-height);
padding: 0 var(--header-chip-pad-x);
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: var(--header-chip-font-size);
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px -2px rgba(37, 99, 235, 0.35);
}
.cloud-sync-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px -2px rgba(37, 99, 235, 0.45);
}
.cloud-sync-btn:active {
transform: translateY(0);
}
.cloud-sync-btn svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.cloud-sync-btn span {
font-size: inherit;
}
.cloud-readonly-banner {
margin-bottom: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(14, 165, 233, 0.08);
border: 1px solid rgba(37, 99, 235, 0.18);
color: rgba(30, 64, 175, 0.92);
font-size: 13px;
font-weight: 550;
}
.empty {
text-align: center;
padding: 40px 20px;
@@ -767,6 +1008,11 @@ onUnmounted(() => {
justify-content: flex-start;
}
.search-input {
width: 130px;
min-width: 110px;
}
.sort-select {
height: var(--header-chip-height);
padding: 0 calc(var(--header-chip-pad-x) + 14px) 0 var(--header-chip-pad-x);
@@ -888,6 +1134,11 @@ onUnmounted(() => {
justify-content: flex-start;
}
.search-input {
width: 120px;
min-width: 100px;
}
.sort-select {
height: var(--header-chip-height);
padding: 0 calc(var(--header-chip-pad-x) + 14px) 0 var(--header-chip-pad-x);
@@ -0,0 +1,100 @@
export interface CloudSyncSettings {
serverUrl: string;
enabled: boolean;
}
export interface CloudSyncSession {
accessToken: string;
expiresAtUtc: string;
userId: string;
role: string;
permissions: string[];
}
const SETTINGS_KEY = 'Hua.Todo_cloud_sync_settings';
const SESSION_KEY = 'Hua.Todo_cloud_sync_session';
/**
* 读写云同步设置与会话信息。
*
* - 设置(serverUrl/enabled)需要跨重启保留,存 localStorage
* - 会话(accessToken 等)默认不持久化到磁盘,存 sessionStorage(关闭应用即失效)
*
* v1.2.0 仅实现“配置 + 登录 + 拉取任务”的最小闭环;更细的“可控落盘/内存模式”由 06-* 任务接管。
*/
export class CloudSyncStorage {
/**
* 读取云同步设置;不存在时返回默认值。
*/
static loadSettings(): CloudSyncSettings {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) {
return { serverUrl: '', enabled: false };
}
const parsed = JSON.parse(raw) as Partial<CloudSyncSettings>;
return {
serverUrl: typeof parsed.serverUrl === 'string' ? parsed.serverUrl : '',
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : false,
};
} catch {
return { serverUrl: '', enabled: false };
}
}
/**
* 保存云同步设置。
*/
static saveSettings(settings: CloudSyncSettings): void {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
/**
* 清空云同步设置(不会影响本地任务数据)。
*/
static clearSettings(): void {
localStorage.removeItem(SETTINGS_KEY);
}
/**
* 读取云同步会话;不存在时返回 null。
*/
static loadSession(): CloudSyncSession | null {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CloudSyncSession>;
if (!parsed.accessToken || typeof parsed.accessToken !== 'string') return null;
return {
accessToken: parsed.accessToken,
expiresAtUtc: typeof parsed.expiresAtUtc === 'string' ? parsed.expiresAtUtc : '',
userId: typeof parsed.userId === 'string' ? parsed.userId : '',
role: typeof parsed.role === 'string' ? parsed.role : '',
permissions: Array.isArray(parsed.permissions)
? parsed.permissions.filter((p): p is string => typeof p === 'string')
: [],
};
} catch {
return null;
}
}
/**
* 保存云同步会话到 sessionStorage。
*/
static saveSession(session: CloudSyncSession): void {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
/**
* 清空云同步会话(登出)。
*/
static clearSession(): void {
sessionStorage.removeItem(SESSION_KEY);
}
}
export default CloudSyncStorage;
+24 -17
View File
@@ -2,23 +2,30 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
outDir: resolve(__dirname, 'dist'),
emptyOutDir: true,
},
define: {
'window.__API_BASE_URL__': JSON.stringify('/api')
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
export default defineConfig(({ mode }) => {
// Use `vite build --mode maui` to emit artifacts into the MAUI app's `wwwroot` for embedded hosting.
const outDir = mode === 'maui'
? resolve(__dirname, '../Hua.Todo.Maui/wwwroot')
: resolve(__dirname, 'dist')
return {
plugins: [vue()],
build: {
outDir,
emptyOutDir: true,
},
define: {
'window.__API_BASE_URL__': JSON.stringify('/api')
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
},
},
},
}
})
+49
View File
@@ -0,0 +1,49 @@
param(
[switch]$Force = $false,
[switch]$StartMaui = $false
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " Hua.Todo 三件套启动" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$pwsh = (Get-Command "pwsh" -ErrorAction SilentlyContinue)?.Source
if (-not $pwsh) {
$pwsh = (Get-Command "powershell" -ErrorAction SilentlyContinue)?.Source
}
if (-not $pwsh) {
Write-Host "❌ 未找到 PowerShell 可执行文件(pwsh/powershell" -ForegroundColor Red
exit 1
}
$forceArgs = @()
if ($Force) { $forceArgs += "-Force" }
Write-Host "[1/3] 启动后端 Host (5173)..." -ForegroundColor Yellow
Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-host.ps1")) + $forceArgs | Out-Null
Write-Host "[2/3] 启动前端 Vite (5174)..." -ForegroundColor Yellow
Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-forend.ps1")) + $forceArgs | Out-Null
Write-Host "[3/3] 启动 MAUI..." -ForegroundColor Yellow
if ($StartMaui) {
Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-maui.ps1"), "-SkipWebBuild") + $forceArgs | Out-Null
Write-Host "✓ 已通过脚本启动 MAUI(跳过内置 Web 构建)" -ForegroundColor Green
} else {
Write-Host "✓ 已启动 Host + Vite。请在 Visual Studio 中启动 Hua.Todo.MauiF5)进行调试。" -ForegroundColor Green
}
Write-Host ""
Write-Host "💡 约定端口:" -ForegroundColor Yellow
Write-Host " - Host API: http://localhost:5173" -ForegroundColor Cyan
Write-Host " - Vite Dev: http://localhost:5174" -ForegroundColor Cyan
Write-Host ""
Write-Host "📝 提示:" -ForegroundColor Yellow
Write-Host " - 使用 -Force 可强制重启已运行的进程" -ForegroundColor Gray
Write-Host " - 使用 -StartMaui 也可用脚本拉起 MAUI(不等同于 VS 调试启动)" -ForegroundColor Gray
Write-Host ""
+90
View File
@@ -0,0 +1,90 @@
param(
[switch]$Force = $false
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " Hua.Todo.Host 启动脚本" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$hostProjectPath = Join-Path $PSScriptRoot "src\Hua.Todo.Host"
$hostProjectFile = Join-Path $hostProjectPath "Hua.Todo.Host.csproj"
if (!(Test-Path $hostProjectFile)) {
Write-Host "❌ Host 项目文件不存在: $hostProjectFile" -ForegroundColor Red
exit 1
}
if (!(Get-Command "dotnet" -ErrorAction SilentlyContinue)) {
Write-Host "❌ 未找到 dotnet,请先安装 .NET SDK" -ForegroundColor Red
exit 1
}
Write-Host "[1/2] 检查 Hua.Todo.Host 是否已在运行..." -ForegroundColor Yellow
$runningProcesses = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe'" | Where-Object {
$_.CommandLine -and (
$_.CommandLine -like "*Hua.Todo.Host*" -or
$_.CommandLine -like "*Hua.Todo.Host.dll*"
)
}
if ($runningProcesses) {
$pids = ($runningProcesses.ProcessId | Sort-Object) -join ", "
Write-Host "⚠️ Hua.Todo.Host 可能已在运行中" -ForegroundColor Yellow
Write-Host " 进程 ID: ($pids)" -ForegroundColor Gray
if ($Force) {
Write-Host ""
Write-Host "🔄 强制关闭并重新启动..." -ForegroundColor Yellow
try {
$runningProcesses | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop }
Write-Host "✅ 旧进程已停止" -ForegroundColor Green
Start-Sleep -Seconds 1
} catch {
Write-Host "❌ 停止进程失败: $_" -ForegroundColor Red
exit 1
}
} else {
Write-Host " 如果需要重新启动,请使用 -Force 参数" -ForegroundColor Gray
Write-Host ""
Write-Host "💡 API 地址:" -ForegroundColor Yellow
Write-Host " http://localhost:5173" -ForegroundColor Cyan
Write-Host ""
exit 0
}
} else {
Write-Host "✓ Hua.Todo.Host 未运行" -ForegroundColor Green
Write-Host ""
}
Write-Host "[2/2] 启动 Hua.Todo.Host..." -ForegroundColor Yellow
Write-Host "📂 工作目录: $hostProjectPath" -ForegroundColor Gray
Write-Host "🚀 启动服务..." -ForegroundColor Green
Write-Host ""
try {
$argumentList = @(
"run",
"--project", $hostProjectFile,
"--launch-profile", "http"
)
$hostProcess = Start-Process -FilePath "dotnet" -ArgumentList $argumentList -WorkingDirectory $hostProjectPath -PassThru
Write-Host "✅ Hua.Todo.Host 已启动" -ForegroundColor Green
Write-Host " 进程 ID: ($($hostProcess.Id))" -ForegroundColor Gray
Write-Host ""
Write-Host "💡 API 地址:" -ForegroundColor Yellow
Write-Host " http://localhost:5173" -ForegroundColor Cyan
Write-Host ""
Write-Host "📝 提示:" -ForegroundColor Yellow
Write-Host " - 使用 -Force 参数可以强制重启" -ForegroundColor Gray
Write-Host ""
} catch {
Write-Host "❌ 启动失败: $_" -ForegroundColor Red
exit 1
}