Compare commits
4 Commits
main
...
8443d14ba6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8443d14ba6 | |||
| 04263dff4e | |||
| 7a4c516a20 | |||
| 18d37fdd24 |
@@ -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
|
||||
|
||||
@@ -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 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
|
||||
|
||||
@@ -1,32 +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) 中记录了本次变更?
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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。
|
||||
|
||||
+30
-20
@@ -173,22 +173,28 @@ Hua.Todo/
|
||||
- WebView 容器管理
|
||||
- 本地 HTTP 服务器启动
|
||||
|
||||
**调试与接口文档**:
|
||||
- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。
|
||||
|
||||
**关键组件**:
|
||||
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
|
||||
- `App.xaml.cs`: 应用程序主入口
|
||||
- `WebViewContainer`: 封装 WebView 控件
|
||||
- 平台特定服务: 快捷键、通知等
|
||||
|
||||
### 4.2 后端 API 项目 (Hua.Todo.Api)
|
||||
### 4.2 后端 API 项目 (Hua.Todo.Host)
|
||||
**职责**:
|
||||
- 提供 RESTful API 接口
|
||||
- 业务逻辑处理
|
||||
- 数据访问和持久化
|
||||
- 本地 HTTP 服务器托管
|
||||
|
||||
**接口文档**:
|
||||
- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。
|
||||
|
||||
**关键组件**:
|
||||
- `Controllers`: API 端点实现
|
||||
- `Services`: 业务逻辑服务
|
||||
- `CloudSync`: 云同步 Minimal APIs(`/auth`、`/tasks`、`/sync`、`/security`)
|
||||
- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API(`/api/{service}/...`)对外暴露
|
||||
- `Data`: 数据访问层和数据库上下文
|
||||
- `Program.cs`: API 服务器配置和启动
|
||||
|
||||
@@ -220,33 +226,36 @@ Hua.Todo/
|
||||
## 5. HTTP API 设计
|
||||
|
||||
### 5.1 API 基础配置
|
||||
- **基础 URL**: `http://localhost:5000/api`
|
||||
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`)
|
||||
- **基础 URL(MAUI 内嵌模式)**: `{HostUrl}/api`(`HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`)
|
||||
- **数据格式**: JSON
|
||||
- **认证方式**: 暂无(本地应用)
|
||||
- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证)
|
||||
- **跨域配置**: 允许本地跨域请求
|
||||
|
||||
### 5.2 API 端点设计
|
||||
|
||||
#### 任务管理 API
|
||||
```
|
||||
GET /api/tasks # 获取任务列表
|
||||
GET /api/tasks/{id} # 获取单个任务
|
||||
POST /api/tasks # 创建任务
|
||||
PUT /api/tasks/{id} # 更新任务
|
||||
DELETE /api/tasks/{id} # 删除任务
|
||||
PATCH /api/tasks/{id}/complete # 标记任务完成
|
||||
GET /api/task # 获取任务列表(默认:全部)
|
||||
GET /api/task/active # 获取未完成任务
|
||||
GET /api/task/completed # 获取已完成任务
|
||||
GET /api/task/{id} # 获取单个任务
|
||||
POST /api/task # 创建任务
|
||||
PUT /api/task # 更新任务(通过 Body 内的 id 定位)
|
||||
DELETE /api/task/{id} # 删除任务
|
||||
PATCH /api/task/{id}/toggle # 切换完成状态
|
||||
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
|
||||
```
|
||||
|
||||
#### 设置管理 API
|
||||
#### 云同步 API(Host 模式)
|
||||
```
|
||||
GET /api/settings # 获取设置
|
||||
PUT /api/settings # 更新设置
|
||||
```
|
||||
|
||||
#### 同步 API
|
||||
```
|
||||
POST /api/sync/pull # 拉取远程数据
|
||||
POST /api/sync/push # 推送本地数据
|
||||
POST /auth/bootstrap # 初始化管理员(仅首次)
|
||||
POST /auth/login # 登录
|
||||
POST /auth/step-up # 二次验证(提升权限)
|
||||
GET /tasks/ # 获取云端任务(只读)
|
||||
POST /sync/ # 推送/拉取合并同步
|
||||
GET /security/policy # 获取安全策略
|
||||
PUT /security/policy # 更新安全策略
|
||||
```
|
||||
|
||||
## 6. 数据库设计
|
||||
@@ -310,6 +319,7 @@ CREATE TABLE Settings (
|
||||
- **Windows**: WebView2 运行时要求
|
||||
- **macOS**: 代码签名和公证
|
||||
- **移动端**: 应用商店发布配置
|
||||
- **Linux**: .NET MAUI 无官方 Linux 目标,需要引入独立桌面宿主(例如基于 WebKitGTK 的方案)并处理运行时依赖与打包格式
|
||||
|
||||
## 9. 性能优化
|
||||
|
||||
|
||||
+24
-3
@@ -3,17 +3,37 @@
|
||||
## 🔄 版本更新
|
||||
|
||||
### 版本策略
|
||||
|
||||
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
|
||||
- v1.0.0:初始 WPF 版本
|
||||
- v1.1.0:MAUI + WebView 跨平台版本
|
||||
- v1.2.0 (规划中):Linux 支持与增强功能
|
||||
|
||||
### v1.2.0(开发中,2026-04-07)
|
||||
|
||||
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
|
||||
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
|
||||
- **MAUI(Windows)内嵌 API 文档**:Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
|
||||
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。
|
||||
- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作“后端专用路径”排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。
|
||||
- **MAUI 多平台构建开关**:在 Windows 开发机上默认仅构建 Android + Windows 目标,避免 iOS/MacCatalyst 目标在非 macOS 环境触发运行时包缺失(NETSDK1082);在 macOS 上仍会包含 iOS/MacCatalyst 目标。
|
||||
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`。
|
||||
- **Windows 发布打包修复**:Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。
|
||||
- **Windows WebView2 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
|
||||
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
|
||||
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
|
||||
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
|
||||
|
||||
### v1.1.1 (2026-04-06)
|
||||
|
||||
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
|
||||
- **项目结构说明校准**:修正 README.md 和技术文档中对 `Hua.Todo.Host`、`Hua.Todo.Application` 等模块的路径与职责描述。
|
||||
- **端口配置校准**:修正文档中关于前端与后端 API 的端口说明(5173/5174)。
|
||||
- **PRD 校准**:移除 v1.2.0 PRD 中“本地迭代不支持”表述与“数据迁移(导入/导出)”小节。
|
||||
- **PRD 校准**:移除 v1.2.0 PRD 中“云同步”需求。
|
||||
|
||||
### v1.1.0 更新内容
|
||||
|
||||
- 重构为 MAUI + WebView 架构
|
||||
- 实现跨平台支持 (Windows, macOS, Android, iOS)
|
||||
- 使用 HTTP API 进行前后端通信
|
||||
@@ -22,9 +42,10 @@
|
||||
- 实现子任务支持
|
||||
|
||||
### v1.2.0 规划内容 (即将推出)
|
||||
|
||||
- **Linux 官方支持**:正式适配 Linux 平台。
|
||||
- **搜索与过滤**:支持按标题搜索、按优先级和状态过滤。
|
||||
- **本地提醒**:支持设置任务提醒时间并发送本地通知。
|
||||
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpak(manifest/desktop entry/AppStream)基础结构。
|
||||
- **关键词检索**:支持按任务标题关键词搜索。
|
||||
- **标签系统**:引入多标签支持,提升任务组织效率。
|
||||
- **暗色模式**:全平台适配暗色/深色主题。
|
||||
- **数据导出导入**:支持 JSON 格式数据备份与迁移。
|
||||
- **数据导出导入(后续)**:支持 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 - Linux:Avalonia 入口 + WebView 承载 Vue
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增一个 **Linux 桌面端入口**(Avalonia)作为 Hua.Todo 的 Linux 宿主
|
||||
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
|
||||
- 复用既有“前端 ↔ 本地 API”的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
|
||||
|
||||
## 范围
|
||||
|
||||
- 新增/调整项目结构以容纳 Avalonia 入口(建议新项目:`src/Hua.Todo.Avalonia` 或 `src/Hua.Todo.Desktop.Avalonia`)
|
||||
- 在 Avalonia 中接入可在 Linux 运行的 WebView 控件(需要先调研与选型)
|
||||
- 确保能正确加载:
|
||||
- 内嵌 WebServer 的 `BaseUrl`
|
||||
- 以及前端静态资源(开发态/生产态策略需明确)
|
||||
|
||||
## 依赖
|
||||
|
||||
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
|
||||
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`)
|
||||
|
||||
## 关键决策点(必须在实现前落定)
|
||||
|
||||
### 1) Avalonia/Linux 可用的 WebView 方案选型
|
||||
|
||||
候选方向(示例,最终以调研结果为准):
|
||||
- WebKitGTK 系(Linux 依赖较重,但适配面广)
|
||||
- Chromium 系(体积/依赖/许可成本需评估)
|
||||
|
||||
选型必须满足的最低能力:
|
||||
- 基础导航与本地资源加载
|
||||
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
|
||||
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证“HTTP API”路径通)
|
||||
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
|
||||
|
||||
### 2) 资源加载策略(与 MAUI 对齐)
|
||||
|
||||
需要明确并实现两种模式:
|
||||
- 开发态:Avalonia WebView 指向前端 dev server(例如 `http://localhost:5173`)或指向本地 WebServer
|
||||
- 生产态:加载随应用交付的静态资源(`wwwroot`)并确保 SPA fallback 生效(访问非 `/assets/*` 的路由能回到 `index.html`)
|
||||
|
||||
## 实现落地(已完成)
|
||||
|
||||
### 1) 项目结构
|
||||
|
||||
- 新增 Linux 桌面端入口项目:`src/Hua.Todo.Avalonia`
|
||||
- 入口类型:Avalonia Desktop App(ClassicDesktopLifetime)
|
||||
|
||||
### 2) WebView 方案选型(已落定)
|
||||
|
||||
- 采用:`WebView.Avalonia` + `WebView.Avalonia.Desktop`
|
||||
- Windows:依赖 WebView2 Runtime
|
||||
- Linux:依赖 GTK + WebKitGTK(运行环境缺失时 WebView 会初始化失败)
|
||||
|
||||
### 3) 资源加载策略(与 MAUI 对齐)
|
||||
|
||||
- 生产态(默认):内嵌 WebServer 托管 `wwwroot` 静态资源,WebView 指向 `HostUrl`
|
||||
- 开发态:将 `IsUsingStatic=false`,WebView 指向 `ForEndUrl`(例如 Vite dev server)
|
||||
|
||||
### 4) 前端契约对齐(与 MAUI 一致)
|
||||
|
||||
- 在 WebView 导航完成后注入:
|
||||
- `window.__API_BASE_URL__ = "${HostUrl}/api"`
|
||||
- `window.mauiInterop`(事件名/字段名与现有前端保持一致)
|
||||
|
||||
### 5) 本地 API 与数据库初始化
|
||||
|
||||
- 内嵌 WebServer 使用 Kestrel 启动并注册动态 API 与 Controllers
|
||||
- 启动时执行数据库迁移(Migrate),并设置 SQLite WAL 模式以降低锁冲突风险
|
||||
- 默认 SQLite 路径使用用户目录(`LocalApplicationData/Hua.Todo/Hua.Todo.db`),避免安装目录无写权限导致启动失败
|
||||
|
||||
## 实施步骤(建议)
|
||||
|
||||
1. 新建 Avalonia 宿主项目
|
||||
- 提供 App/Window/MainView 基础结构
|
||||
2. 接入 WebView 控件并完成最小加载闭环
|
||||
- 能显示 `index.html`,能打开前端路由
|
||||
3. 对齐与 MAUI 端一致的“前端契约”
|
||||
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`)
|
||||
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
|
||||
4. 复用本地 API(内嵌 WebServer)
|
||||
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
|
||||
- 明确 Linux 端是否也走“内嵌 WebServer + WebView 指向 BaseUrl”的方式(推荐一致化,减少前端差异)
|
||||
5. 输入法/字体/DPI 验证与可配置化
|
||||
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
|
||||
- WebView 正常渲染前端主界面
|
||||
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
|
||||
- 页面路由/刷新不会出现 404(SPA fallback 生效)
|
||||
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
|
||||
@@ -0,0 +1,41 @@
|
||||
# 02 - Linux:打包、依赖与交付产物
|
||||
|
||||
## 目标
|
||||
|
||||
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到“用户机器无需手动补大量依赖即可运行”
|
||||
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
|
||||
|
||||
## 范围
|
||||
|
||||
- 构建脚本与产物输出
|
||||
- 运行时依赖打包策略(尤其是 WebView 相关)
|
||||
- 基础安装/运行说明(README / docs 更新)
|
||||
|
||||
## 依赖
|
||||
|
||||
- 依赖 `01-*`(Avalonia 入口与 WebView 方案已定、并能在开发机运行)
|
||||
|
||||
## 交付形式(PRD 约束)
|
||||
|
||||
- 优先提供一种“自包含”官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
|
||||
- 同时保留 `.deb` 或 `.tar.gz` 作为补充分发形式(以脚本产出为准)
|
||||
|
||||
## 实施步骤(建议)
|
||||
|
||||
1. 确定 Linux 发行版基线与运行时依赖清单
|
||||
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
|
||||
2. 选择并落地一种自包含交付形式
|
||||
- AppImage:偏“拎包即用”,对依赖捆绑要求高
|
||||
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
|
||||
3. 补充 `.deb` 或 `.tar.gz`
|
||||
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
|
||||
- `.tar.gz`:最通用;需要启动脚本与依赖说明
|
||||
4. 增加启动前自检(可选但推荐)
|
||||
- 发现关键依赖缺失时给出清晰错误提示与修复建议
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 产物可在“干净环境”(尽量接近用户机)安装/运行
|
||||
- 启动后 WebView 能渲染前端,且本地 API 可用
|
||||
- 文档中给出的安装/运行步骤可复现,且与产物一致
|
||||
|
||||
@@ -0,0 +1,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 / 云同步提供可复现的验收步骤
|
||||
|
||||
## 范围
|
||||
|
||||
- README:Features、运行方式、API 说明(如涉及)
|
||||
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
|
||||
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
|
||||
|
||||
## 必做同步点(结合当前仓库现状)
|
||||
|
||||
- **API 端点与项目结构**:对齐实际实现(避免文档仍描述不存在的项目或旧端点)
|
||||
- **Linux 入口说明**:新增 Avalonia 入口的构建/运行/打包方式
|
||||
- **云同步说明**:服务端地址配置、登录、落盘策略与安全机制(RBAC/二次认证)的用户可理解描述
|
||||
- **版本记录**:在 `docs/版本记录.md` 增加 v1.2.0 非琐碎变更条目
|
||||
|
||||
## 验收清单(建议逐项勾选)
|
||||
|
||||
### Linux
|
||||
- [ ] Linux 上可启动并打开主界面
|
||||
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
|
||||
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
|
||||
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
|
||||
|
||||
### Search
|
||||
- [ ] 主界面有搜索框
|
||||
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
|
||||
- [ ] 清空后恢复原列表
|
||||
|
||||
### 云同步(基础可用)
|
||||
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
|
||||
- [ ] 登录后能拉取该用户任务并展示
|
||||
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
|
||||
- [ ] 高风险操作触发二次认证(服务端支持时)
|
||||
|
||||
@@ -1,77 +1,84 @@
|
||||
# Hua.Todo 产品需求文档 (PRD) v1.2.0
|
||||
|
||||
## 1. 项目概述
|
||||
在 v1.1.0 版本成功实现 MAUI + WebView 跨平台架构的基础上,v1.2.0 版本将重点提升任务管理的精细化程度,完善平台支持(Linux),并增强用户体验(暗色模式、提醒功能)。
|
||||
|
||||
在 v1.1.0 版本成功实现 MAUI + WebView 跨平台架构的基础上,v1.2.0 版本将以“**MAUI 入口保持不动 + Linux 新增 Avalonia 入口**”的方式落地 Linux 支持(继续使用 WebView 承载 Vue 前端),并在此基础上探索 Avalonia 对其他终端的支持情况;若验证效果良好,后续版本将推进各终端入口统一切换到 Avalonia。
|
||||
|
||||
## 2. 核心目标
|
||||
|
||||
- **完善平台覆盖**:正式支持 Linux 平台。
|
||||
- **增强任务组织**:引入搜索、过滤和标签系统。
|
||||
- **提升交互体验**:支持本地通知提醒及暗色模式。
|
||||
- **数据安全保障**:实现基础的数据导入导出功能。
|
||||
- **增强任务组织**:引入搜索。
|
||||
- **云同步(基础可用)**:支持用户级任务数据同步与“可控落盘”(在不信任终端场景下可禁止落盘)。
|
||||
|
||||
## 3. 功能需求
|
||||
|
||||
### 3.1 平台支持:Linux 官方支持
|
||||
|
||||
- **客户端框架迁移**:保持原 MAUI 项目不动,实现 Avalonia 对 Linux 端支持,并探索 Avalonia 对 Android 和其他平台的支持。
|
||||
- **终端入口策略(v1.2.0)**:
|
||||
- **保持 MAUI 入口不动**:Windows/macOS/iOS/Android 继续沿用现有 MAUI 入口与 WebView 方案,保证迭代稳定性。
|
||||
- **Linux 使用 Avalonia 作为入口**:新增 Avalonia 桌面端宿主作为 Linux 入口,继续用 WebView 承载同一套 Vue 前端,并复用既有“本地 API ↔ 前端”的交互协议。
|
||||
- **评估全端切换可行性**:在完成 Linux 入口落地后,评估 Avalonia 在 Windows/macOS(以及后续 Android 等)的稳定性、WebView 兼容性与打包成本;若效果良好,后续版本将各终端入口统一切换到 Avalonia。
|
||||
- **适配性**:确保 WebView 在 Linux 环境下的正常渲染与交互。
|
||||
- **打包部署**:提供 Linux 平台的打包脚本(如 .deb 或 .tar.gz)。
|
||||
|
||||
### 3.2 任务检索与过滤 (Search & Filter)
|
||||
### 3.2 任务检索 (Search)
|
||||
|
||||
- **关键词搜索**:在主界面顶部增加搜索框,支持按任务标题模糊匹配。
|
||||
- **高级过滤**:
|
||||
- 按优先级过滤(高、中、低)。
|
||||
- 按标签过滤。
|
||||
- 按创建/完成时间段过滤。
|
||||
|
||||
### 3.3 本地提醒 (Local Reminders)
|
||||
- **提醒设置**:在任务编辑界面增加“提醒时间”字段。
|
||||
- **通知触发**:当达到提醒时间时,通过平台原生 API 发送本地通知(Notification)。
|
||||
- **状态反馈**:已过期的提醒任务在列表中有明显的视觉标识。
|
||||
### 3.3 云同步(基础可用)
|
||||
|
||||
### 3.4 标签系统 (Tag System)
|
||||
- **多标签支持**:一个任务可关联多个标签。
|
||||
- **标签管理**:支持自定义标签名称和颜色。
|
||||
- **快速归类**:通过标签快速筛选相关任务。
|
||||
#### 3.3.1 服务端地址配置(用户手动指定)
|
||||
|
||||
### 3.5 主题增强:暗色模式 (Dark Mode)
|
||||
- **自动切换**:根据系统主题自动切换浅色/深色模式。
|
||||
- **手动控制**:在设置中提供手动切换开关。
|
||||
- **UI 适配**:Vue 前端及 MAUI 原生部分均需完成暗色模式适配。
|
||||
- **用户手动指定服务端地址**:首次启用同步时,用户需要手动填写服务端地址(例如 `https://example.com`),并允许后续在设置中修改。
|
||||
- **地址有效性提示**:客户端需对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息。
|
||||
|
||||
### 3.6 数据迁移 (Data Migration)
|
||||
- **导出功能**:支持将所有任务数据导出为 JSON 格式文件。
|
||||
- **导入功能**:支持从 JSON 文件恢复数据,解决跨设备迁移问题。
|
||||
#### 3.3.2 权限与二次认证(RBAC + 二次认证)
|
||||
|
||||
- **RBAC 权限模型**:服务端必须提供基于角色/权限的访问控制(Role-Based Access Control),以支持后续按功能、数据域进行授权。
|
||||
- **二次认证**:对高风险操作(例如启用/关闭同步、切换账号、调整“是否允许落盘”等安全相关配置)应支持二次认证机制(例如二次口令/一次性验证码等),具体实现以服务端能力为准。
|
||||
|
||||
#### 3.3.3 基于用户的数据隔离
|
||||
|
||||
- **用户级数据隔离**:服务端必须按用户隔离任务数据,任何同步、检索与配置读取都必须绑定当前登录用户上下文。
|
||||
- **最小权限原则**:客户端仅请求完成任务同步所需的最小权限范围,避免默认授予不必要的数据访问能力。
|
||||
|
||||
#### 3.3.4 同步工作流(登录后获取任务 + 可控落盘)
|
||||
|
||||
- **登录后拉取任务**:用户登录成功后,客户端从服务端获取该用户的任务信息并更新到前端展示。
|
||||
- **服务端配置驱动的落盘策略**:客户端需读取服务端针对该用户(或该会话/终端)的安全配置,决定任务信息是否允许落盘到本地存储。
|
||||
- **不信任终端禁止落盘**:当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据。
|
||||
|
||||
## 4. 技术实现要点
|
||||
|
||||
### 4.1 Linux 适配
|
||||
- 使用 WebKitGTK 作为 WebView 容器。
|
||||
- 适配 Linux 下的全局快捷键实现。
|
||||
|
||||
### 4.2 本地通知
|
||||
- 使用 `Plugin.LocalNotification` 或 MAUI 自带的通知机制(取决于 .NET 10 的最新支持)。
|
||||
- 确保后台服务在移动端能准时触发提醒。
|
||||
v1.1.0 起客户端使用 **MAUI WebView** 承载 Vue 前端;在 v1.2.0 中,保持现有 MAUI 入口不动,同时为 Linux 新增 **Avalonia 桌面端**入口,并继续以 WebView 承载同一套 Vue 前端。后续将基于 Linux 端落地经验评估 Avalonia 对 Windows/macOS(以及后续 Android 等)的覆盖与稳定性,满足条件后推进全端入口统一。
|
||||
|
||||
### 4.3 标签数据结构
|
||||
- 在 `TaskEntity` 中增加 `Tags` 关联(多对多关系或简单的 JSON 存储)。
|
||||
#### 4.1.1 Linux 解决方案(Avalonia 入口 + WebView 承载 Vue)
|
||||
|
||||
### 4.4 主题管理
|
||||
- 使用 CSS Variables (Custom Properties) 管理前端主题颜色。
|
||||
- 监听 `(prefers-color-scheme: dark)` 媒体查询。
|
||||
- **入口与架构**:Linux 端新增 Avalonia 桌面宿主作为应用入口;加载同一套 Vue 前端,并保持与现有 MAUI 端一致的“前端调用本地 API”协议与数据模型,确保业务逻辑与 UI 复用。
|
||||
- **WebView 方案**:选用可在 Avalonia/Linux 运行的 WebView 控件承载前端(以 WebKitGTK/Chromium 系为候选实现),统一约束:支持基础导航/本地资源加载、JS ↔ Native 双向通信、DevTools(至少开发环境可用)。
|
||||
- **发行版与依赖策略**:定义最低支持范围(例如 Ubuntu LTS 系列作为基线),并将 WebView 运行时依赖纳入打包交付(避免用户手动安装大量依赖导致不可用)。
|
||||
- **输入法/字体/DPI**:提供默认 CJK 字体与渲染配置,验证 IME、缩放、Wayland/X11 关键组合;遇到环境差异时以“可配置 + 明确降级”保证可用性。
|
||||
- **快捷键策略**:全局快捷键在 Linux 上采用“尽力而为”的可插拔实现;在 Wayland 等受限环境下自动降级为应用内快捷键,并在设置中提供可配置与提示。
|
||||
- **交付形式**:优先提供一种“自包含”的交付产物(例如 AppImage/Flatpak 之一)作为官方安装方式,同时保留 `.deb/.tar.gz` 作为补充分发形式(以脚本产出为准)。
|
||||
|
||||
## 5. 版本规划
|
||||
|
||||
### 5.1 v1.2.0 目标
|
||||
|
||||
- [ ] Linux 平台完整支持与打包脚本。
|
||||
- [ ] 搜索框及多维过滤功能。
|
||||
- [ ] 本地提醒(提醒时间设置与通知触发)。
|
||||
- [ ] 基础标签功能(创建、关联、过滤)。
|
||||
- [ ] 全局暗色模式适配。
|
||||
- [ ] 数据导入导出(JSON)。
|
||||
- [ ] 搜索框(关键词检索)。
|
||||
- [ ] 云同步(基础可用:用户手动配置服务端地址、RBAC + 二次认证、用户级数据隔离、服务端配置驱动的可控落盘)。
|
||||
|
||||
### 5.2 后续版本规划
|
||||
- **v1.3.0**:云同步功能(接入第三方云盘或自建服务)。
|
||||
- **v1.4.0**:统计图表(任务完成趋势、时间分配分析)。
|
||||
|
||||
- **v1.3.0**:数据导入导出(JSON)、统计图表(任务完成趋势、时间分配分析)。
|
||||
- **v1.4.0**:高级过滤(优先级/时间段)与更丰富的视图(如看板/日历)。
|
||||
|
||||
## 6. 风险评估
|
||||
|
||||
- **Linux 碎片化**:不同发行版下 WebView 的依赖库可能不一致。
|
||||
- **移动端后台限制**:Android/iOS 对后台进程的限制可能导致提醒不准时。
|
||||
- **凭据与合规**:第三方服务鉴权方式差异大,且必须保证凭据安全存储与最小化权限。
|
||||
- **终端可信度与数据落盘**:若需支持“不信任终端不落盘”,需要明确“可信度判定与配置下发”的服务端策略,并保证客户端在无落盘场景下的可用性与体验。
|
||||
|
||||
@@ -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.gz;Flatpak/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-Avalonia入口与WebView.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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+198
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+203
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Runtime;Linux 需要 WebKitGTK)。",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 Runtime(Evergreen),安装完成后重新打开应用。",
|
||||
Opacity = 0.85
|
||||
},
|
||||
new Button
|
||||
{
|
||||
Text = "打开下载页面",
|
||||
Command = new Command(async () =>
|
||||
{
|
||||
await Launcher.Default.OpenAsync(downloadUrl);
|
||||
})
|
||||
},
|
||||
new Button
|
||||
{
|
||||
Text = "退出应用",
|
||||
Command = new Command(() =>
|
||||
{
|
||||
Microsoft.Maui.Controls.Application.Current?.Quit();
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Content = new ScrollView { Content = content };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"IsUsingStatic": true,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5174"
|
||||
"ForEndUrl": "http://localhost:5057"
|
||||
},
|
||||
"Development": {
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
param(
|
||||
[switch]$Force = $false,
|
||||
[switch]$StartMaui = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "====================================" -ForegroundColor Cyan
|
||||
Write-Host " Hua.Todo 三件套启动" -ForegroundColor Cyan
|
||||
Write-Host "====================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
$pwsh = (Get-Command "pwsh" -ErrorAction SilentlyContinue)?.Source
|
||||
if (-not $pwsh) {
|
||||
$pwsh = (Get-Command "powershell" -ErrorAction SilentlyContinue)?.Source
|
||||
}
|
||||
|
||||
if (-not $pwsh) {
|
||||
Write-Host "❌ 未找到 PowerShell 可执行文件(pwsh/powershell)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$forceArgs = @()
|
||||
if ($Force) { $forceArgs += "-Force" }
|
||||
|
||||
Write-Host "[1/3] 启动后端 Host (5173)..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-host.ps1")) + $forceArgs | Out-Null
|
||||
|
||||
Write-Host "[2/3] 启动前端 Vite (5174)..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-forend.ps1")) + $forceArgs | Out-Null
|
||||
|
||||
Write-Host "[3/3] 启动 MAUI..." -ForegroundColor Yellow
|
||||
if ($StartMaui) {
|
||||
Start-Process -FilePath $pwsh -ArgumentList @("-NoExit", "-File", (Join-Path $PSScriptRoot "start-maui.ps1"), "-SkipWebBuild") + $forceArgs | Out-Null
|
||||
Write-Host "✓ 已通过脚本启动 MAUI(跳过内置 Web 构建)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "✓ 已启动 Host + Vite。请在 Visual Studio 中启动 Hua.Todo.Maui(F5)进行调试。" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "💡 约定端口:" -ForegroundColor Yellow
|
||||
Write-Host " - Host API: http://localhost:5173" -ForegroundColor Cyan
|
||||
Write-Host " - Vite Dev: http://localhost:5174" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "📝 提示:" -ForegroundColor Yellow
|
||||
Write-Host " - 使用 -Force 可强制重启已运行的进程" -ForegroundColor Gray
|
||||
Write-Host " - 使用 -StartMaui 也可用脚本拉起 MAUI(不等同于 VS 调试启动)" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user