feat:云同步,部分功能生效

This commit is contained in:
ShaoHua
2026-06-12 00:49:03 +08:00
parent c54f2e2ecc
commit a7ba814833
34 changed files with 1532 additions and 471 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+90 -45
View File
@@ -1,30 +1,27 @@
# Hua.Todo 跨平台代办管理应用 v1.2.8 # Hua.Todo 跨平台代办管理应用 v1.2.8
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。 一个基于 **WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer** 架构开发的跨平台代办管理应用,支持 **Windows、macOS、Android、iOS 和 Linux** 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点 ## 🚀 功能特点
### 核心功能 ### 核心功能
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux - **跨平台支持**:基于 MAUI + Avalonia + WebView 架构,覆盖 Windows、macOS、Android、iOS 和 Linux 五大平台
- **任务管理**:支持创建、编辑、删除、完成状态切换子任务管理 - **任务管理**:支持任务创建、编辑、删除、完成状态切换,以及子任务管理
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略SecurityPolicy配置 - **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略配置
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感 - **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析 - **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试 - **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试
- **全局快捷键**:支持系统级快捷键快速唤起应用
## 📦 安装与使用 ## 📦 安装与使用
### 环境要求 ### 环境要求
- **后端** - **后端**.NET 10 SDK + Visual Studio 2022 或更高版本
- .NET 10 SDK - **前端**Node.js 18+ + npm 或 yarn
- Visual Studio 2022 或更高版本
- **前端**
- Node.js 18+
- npm 或 yarn
### 快速开始 ### 快速开始
#### 1. 克隆或下载项目 #### 1. 克隆项目
```bash ```bash
git clone <仓库地址> git clone <仓库地址>
cd Hua.Todo cd Hua.Todo
@@ -37,7 +34,7 @@ dotnet restore
dotnet run dotnet run
``` ```
API 将在 `http://localhost:5173` 启动 API 将在 `http://localhost:5173` 启动
开发环境`ASPNETCORE_ENVIRONMENT=Development`下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger` 开发环境下提供 Swagger UI`http://localhost:5173/swagger`
#### 3. 启动前端 Web #### 3. 启动前端 Web
```bash ```bash
@@ -45,31 +42,38 @@ cd src/Hua.Todo.Web
npm install npm install
npm run dev npm run dev
``` ```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173` 前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到后端
#### 4. 启动 MAUI 客户端(Windows 三件套开发) #### 4. 启动 MAUI 客户端(Windows 三件套开发)
- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)``Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。 推荐使用脚本一键启动:
- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI):
```powershell ```powershell
.\start-dev.ps1 .\start-dev.ps1
``` ```
### Windows 交付产物(安装包) ### 构建与发布
- 运行 `publish-windows.ps1` 默认使用 `Release` 配置生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/` #### Windows 交付产物
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`),且均默认使用 `Release` 配置。 ```powershell
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件) # 生成 Windows 安装包(Inno Setup
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json``WebServer.IsUsingStatic=true` .\publish-windows.ps1
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件) ```
输出:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`
### Linux 交付产物v1.2.0 #### Linux 交付产物
```powershell
- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux` # 生成 Linux 发布包
- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/` .\publish-linux.ps1
```
或使用统一发布脚本:
```powershell
# 默认发布 Windows + Linux
.\publish.ps1
# 仅发布 Windows
.\publish.ps1 -Windows
```
### 使用说明 ### 使用说明
- **添加任务**:在前端界面输入任务内容,设置优先级,点击添加按钮 - **添加任务**:在前端界面输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成) - **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
- **完成任务**:点击任务前的复选框切换完成状态 - **完成任务**:点击任务前的复选框切换完成状态
- **删除任务**:点击删除按钮移除任务 - **删除任务**:点击删除按钮移除任务
@@ -86,31 +90,70 @@ Hua.Todo/
├── src/ # 源代码目录 ├── src/ # 源代码目录
│ ├── Hua.Todo.Core/ # 领域实体与基础接口 │ ├── Hua.Todo.Core/ # 领域实体与基础接口
│ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现 │ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现
│ │ ├── CloudSync/ # 云同步模块
│ │ ├── Data/ # 数据访问层
│ │ └── DynamicApi/ # 动态 API 生成
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel) │ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite) │ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS) │ ├── Hua.Todo.Maui/ # 跨平台客户端 (Windows/Android/iOS/macOS)
── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux) ── Hua.Todo.Avalonia/ # 桌面客户端 (Windows/macOS/Linux)
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件 ├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档 └── README.md # 项目说明文档
``` ```
### 技术栈
| 分类 | 技术 |
|------|------|
| 后端语言 | C# 13 (.NET 10) |
| UI 框架 | MAUI + Avalonia |
| 服务器 | Kestrel (ASP.NET Core) |
| 数据访问 | Entity Framework Core 10 |
| 数据库 | SQLite |
| 前端框架 | Vue.js 3 + TypeScript |
| 构建工具 | Vite 5+ |
| HTTP 客户端 | Axios |
### API 端点 ### API 端点
- `GET /api/task` - 获取任务列表(默认:全部)
- `GET /api/task/active` - 获取未完成任务 #### 任务管理
- `GET /api/task/completed` - 获取已完成任务 | 方法 | 端点 | 说明 |
- `GET /api/task/{id}` - 获取单个任务 |------|------|------|
- `POST /api/task` - 创建任务 | GET | `/api/task` | 获取任务列表(默认:全部) |
- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位) | GET | `/api/task/active` | 获取未完成任务 |
- `PATCH /api/task/{id}/toggle` - 切换完成状态 | GET | `/api/task/completed` | 获取已完成任务 |
- `DELETE /api/task/{id}` - 删除任务 | GET | `/api/task/{id}` | 获取单个任务 |
- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表 | POST | `/api/task` | 创建任务 |
| PUT | `/api/task` | 更新任务(通过 Body 内的 id 定位) |
| PATCH | `/api/task/{id}/toggle` | 切换完成状态 |
| DELETE | `/api/task/{id}` | 删除任务 |
| GET | `/api/task/{parentTaskId}/subtasks` | 获取子任务列表 |
#### 云同步(Host 模式)
| 方法 | 端点 | 说明 |
|------|------|------|
| POST | `/auth/bootstrap` | 初始化管理员(仅首次) |
| POST | `/auth/login` | 用户登录 |
| POST | `/auth/step-up` | 二次验证(提升权限) |
| GET | `/tasks/` | 获取云端任务(只读) |
| POST | `/sync/` | 推送/拉取合并同步 |
| GET | `/security/policy` | 获取安全策略 |
| PUT | `/security/policy` | 更新安全策略 |
## 📊 模块说明
- **Hua.Todo.Core**:领域实体层,定义核心实体、枚举及仓储接口
- **Hua.Todo.Application**:应用层,包含业务逻辑、动态 API 生成、云同步服务
- **Hua.Todo.Host**:后端 API 宿主,提供独立运行时环境
- **Hua.Todo.Web**:前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite
- **Hua.Todo.Maui**:跨平台客户端,支持 Windows、Android、iOS 和 macOS
- **Hua.Todo.Avalonia**:桌面客户端,专注 Linux 平台支持,同时兼容 Windows/macOS
## 🤝 交流与贡献 ## 🤝 交流与贡献
- **QQ 交流群**2167048911 (Hua.Todo 交流群) - **QQ 交流群**2167048911 (Hua.Todo 交流群)
- **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo) - **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md) - **贡献指南**:欢迎提交 Pull Request
## 📄 开源协议 ## 📄 开源协议
@@ -123,12 +166,14 @@ Hua.Todo/
- [版本更新历史](docs/manual/版本记录.md) - [版本更新历史](docs/manual/版本记录.md)
- [技术设计文档](docs/manual/技术设计文档.md) - [技术设计文档](docs/manual/技术设计文档.md)
- [代码规范文档](docs/manual/代码规范文档.md) - [代码规范文档](docs/manual/代码规范文档.md)
- [其他信息 (贡献、许可证、联系方式)](docs/manual/其他信息.md) - [部署文档](docs/manual/部署文档.md)
- [其他信息](docs/manual/其他信息.md)
### 项目进度与需求 ### 项目进度与需求
- [产品需求文档](docs/project/产品需求文档.md) - [产品需求文档](docs/project/产品需求文档.md)
- [Android 离线排查计划](docs/project/Android_NotFound_排查计划.md) - [v1.2.0 任务清单](docs/project/v1.2.0-tasks/00-任务总览.md)
- [实现对比文档](docs/project/实现对比文档.md) - [实现对比文档](docs/project/实现对比文档.md)
--- ---
**Hua.Todo** - 跨平台任务管理,让效率无处不在! **Hua.Todo** - 跨平台任务管理,让效率无处不在!
@@ -0,0 +1,332 @@
## 云同步设置 — 服务端化改造方案
### 1. 当前架构概述
#### 1.1 项目层次关系
```
src/
├── Hua.Todo.Application/ ← 共享层(服务端 & 客户端公用)
│ ├── CloudSync/ ← 云同步业务逻辑
│ │ ├── Auth/ ← 认证/授权
│ │ ├── Models/ ← DTO(纯数据合约,两端可用)
│ │ └── Services/ ← 业务服务
│ ├── Data/ ← EF Core DbContext
│ └── DynamicApi/ ← 动态 API 中间件
├── Hua.Todo.Host/ ← ASP.NET 服务端(云同步接口的提供方)
│ └── Program.cs
│ ├── AddApplicationServices() ← 任务 CRUD 服务
│ └── AddCloudSyncServer() ← 云同步认证/授权/端点 **仅服务端**
└── Hua.Todo.Maui/ ← 桌面/移动客户端(嵌入 WebView)
└── MauiProgram.cs
└── AddApplicationServices() ← **仅** 任务 CRUD 服务
(不注册 AddCloudSyncServer,不暴露云同步端点)
```
#### 1.2 两种部署模式
**模式 A:Host 独立服务端(开发/部署)**
```
┌───────────────────────────────┐
│ Vue 前端 (Vite :5174) │
│ cloudClient │
│ baseURL = serverUrl(外部) │ 直连外部
│ → /auth/login │ ────────→ 外部云同步服务
│ probeReachability() │ 直连外部
│ → fetch(serverUrl) │ ────────→
├───────────────────────────────┤
│ proxy /api │
├───────────────────────────────┤
│ ASP.NET Host (:5173) │
│ AddApplicationServices() │ 任务 CRUD
│ AddCloudSyncServer() │ 云同步端点 ← **服务端**
│ ├─ /api/* (本地任务) │
│ └─ /auth/* (云同步) │
│ /tasks/* │
│ /cloud-sync/probe (新增) │
└───────────────────────────────┘
```
**模式 BMAUI 嵌入式 WebView(桌面端/移动端)**
```
┌───────────────────────────────┐
│ MAUI WebView │
│ ┌─────────────────────────┐ │
│ │ Vue 前端 (静态托管) │ │
│ │ cloudClient │ │
│ │ baseURL = 同源 │ │ 同源请求
│ │ → /api/* (本地任务) │──┼───────→ Embedded WebServer
│ │ → /auth/* (❌ 不可用) │ │ (不暴露云同步端点)
│ └─────────────────────────┘ │
├───────────────────────────────┤
│ Embedded WebServer (:5057) │
│ AddApplicationServices() │ 仅任务 CRUD
│ AddCloudSyncServer() │ ❌ 未注册
│ UseDynamicApi() │ 仅本地任务 API
│ MapCloudSyncEndpoints() │ ❌ 未映射
└───────────────────────────────┘
```
> **关键区别**`Hua.Todo.Application` 被两方引用,但云同步能力**仅在 Host 端通过 `AddCloudSyncServer()` 激活**。MAUI 端只使用 `AddApplicationServices()` 获取任务 CRUD 能力,**不暴露也不应暴露云同步端点**。
#### 1.3 核心问题
1. 前端的"保存并探测"使用**浏览器端直接 `fetch()`** 探测外部 URL,受 CORS、证书、网络隔离限制。
2. 前端的"登录"通过 `cloudClient` 直连外部 `serverUrl`session token 存在前端 `sessionStorage`,与 Host 脱节。
3. 在 Host 模式下,Host 本身就是云同步服务,但前端并不知道,额外配置了外部 `serverUrl`
### 2. 改造目标
| 操作 | 当前 | 目标 |
|---|---|---|
| **保存并探测** | 浏览器 `fetch()` 直连 | 前端 → Host 接口 → Application CloudProbeService → 返回结果 |
| **登录** | 前端直连 `serverUrl` | 前端 → Host `/auth/login` |
| **后续云同步请求** | `cloudClient` 直连 `serverUrl` | 统一走 Host(同源请求) |
### 3. 变更详情
#### 3.1 新增:服务端探测接口
> **注意**`CloudProbeService` 和服务端点注册均在 `AddCloudSyncServer()` 路径下,MAUI 端不受影响。
**Application 层新增 `CloudProbeService`**
位置:`src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs`
职责:接收目标 URL,从服务端发起 HTTP 探测,返回探测结果。
**新增 DTO**(位置:`src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs`,或新建 `CloudSync/Models/ProbeDtos.cs`
DTO 是纯数据合约,放在 Application 共享层无副作用,MAUI 端即使不调用也不会产生依赖。
```csharp
/// <summary>
/// 服务端探测请求。
/// </summary>
public class ProbeRequest
{
public string TargetUrl { get; set; } = string.Empty;
}
/// <summary>
/// 服务端探测响应。
/// </summary>
public class ProbeResponse
{
public bool IsReachable { get; set; }
public int? HttpStatus { get; set; }
public bool IsHttps { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>
/// success | warn | error
/// </summary>
public string Type { get; set; } = "error";
}
```
**DI 注册(`CloudSyncServiceCollectionExtensions.AddCloudSyncServer()`**
`AddCloudSyncServer()` 方法末尾追加,确保 `CloudProbeService` 仅在服务端 DI 容器中可用:
```csharp
// CloudSyncServiceCollectionExtensions.AddCloudSyncServer() 末尾新增:
services.AddHttpClient("ProbeClient", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
});
services.AddScoped<CloudProbeService>();
```
> **为什么放在 `AddCloudSyncServer()` 而非 `AddApplicationServices()`**
> - `AddApplicationServices()` 被 MAUI 端调用,MAUI 不需要也不会启动云同步探测
> - `AddCloudSyncServer()` 仅在 Host 的 `Program.cs` 中被调用,确保服务仅注册在服务端
**端点注册(`CloudSyncEndpointExtensions.MapCloudSyncEndpoints()`**
`MapCloudSyncEndpoints()` 方法内部新增端点组:
```csharp
var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup");
probe.MapPost("/probe", ProbeAsync).AllowAnonymous();
```
端点实现:
```csharp
private static async Task<IResult> ProbeAsync(
ProbeRequest request,
CloudProbeService probeService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl))
{
return CloudApiErrors.BadRequest("TargetUrl is required.");
}
var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken);
return Results.Json(result);
}
```
**前端修改** `src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue`
删除 `probeReachability()` 函数(原 L235-L280),`saveServerUrl()` 中改为:
```typescript
// 旧:前端直接 fetch
// probeResult.value = await probeReachability(serverUrlSaved.value);
// 新:调用 Host 的探测接口
const response = await cloudClient.post<ProbeResponse>('/cloud-sync/probe', {
targetUrl: serverUrlSaved.value,
});
probeResult.value = {
type: response.data.type as ProbeType,
title: response.data.title,
desc: response.data.description,
};
```
#### 3.2 修改:登录统一走 Host
**核心思路**Host 自身就是云同步服务方,`/auth/login` 已实现完整认证。前端只需改为走 Host 同源请求。
**前端 `cloudClient.ts` 改造**`src/Hua.Todo.Web/src/api/cloudClient.ts`
移除对外部 `serverUrl` 的依赖:
```typescript
// 改造前(L36-L39):
// const { serverUrl } = CloudSyncStorage.loadSettings();
// if (serverUrl) { config.baseURL = serverUrl; }
// 改造后:不设 baseURL,axios 使用浏览器默认同源
// cloudClient 请求直接打到当前 Host/auth/* , /tasks/* , /cloud-sync/*
```
> **同源适配说明**
> - **Host 模式**Vite dev):Vue 运行在 :5174`/api` 走 proxy 到 Host(:5173),云同步端点 `/auth/*` 需配 Vite proxy 或直接用 Host 地址
> - **MAUI 模式**WebView):Vue 部署在 MAUI 嵌入服务器同源,不需要额外代理
> - 具体方案:在 Vite dev 时增加 proxy 规则将 `/auth`、`/tasks`、`/cloud-sync` 也 proxy 到 Host;或在 cloudClient 中按环境设置 baseURL
**前端 `CloudSyncSettingsDialog.vue` 的 `login()` 无需修改**
`cloudSyncApi.login()` 调用 `cloudClient.post('/auth/login', ...)`,自动打到 Host。
**Host 端 `CloudAuthService.LoginAsync` 已有完整实现**
- 创建 `UserSessionEntity` 写入 DBsession 管理在 Host
- 返回 `LoginResponse``AccessToken` = DB SessionId
- Session 验证由 `SessionAuthenticationHandler` 查询 DB 完成
#### 3.3 Vite 代理配置补充
Vite dev 模式下,除了已有的 `/api` 代理,需**补充云同步端点的代理规则**(`vite.config.ts`):
```typescript
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
// 新增:云同步端点代理到 Host
'/auth': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/tasks': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/sync': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/security': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/cloud-sync': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
},
},
```
> **MAUI 模式无需此配置**MAUI 使用 `vite build --mode maui`,静态部署到嵌入服务器同源。
#### 3.4 `serverUrl` 字段的语义变化
| 维度 | 改造前 | 改造后 |
|---|---|---|
| **用途** | 云同步 API 的 base URL(前端直连) | 仅用于"服务端探测"的目标地址 |
| **存储位置** | `localStorage` | 不变(探测时使用) |
| **影响登录/任务拉取** | 是(前端用它做 baseURL) | 否(走 Host 自身端点) |
### 4. 需要变更的文件清单
| 层 | 文件 | 变更类型 | 说明 |
|---|---|---|---|
| Application | `Services/CloudProbeService.cs` | **新增** | 服务端探测逻辑,注册于 `AddCloudSyncServer()` |
| Application | `Models/AdminDtos.cs`(或新 `Models/ProbeDtos.cs` | **新增 DTO** | `ProbeRequest` / `ProbeResponse`(纯数据合约) |
| Application | `CloudSyncEndpointExtensions.cs` | **修改** | 注册 `POST /cloud-sync/probe` |
| Application | `CloudSyncServiceCollectionExtensions.cs` | **修改** | `AddCloudSyncServer()` 内注册 `CloudProbeService` + `HttpClient` |
| Host | `Program.cs` | **无需修改** | `MapCloudSyncEndpoints()` 自动包含新端点 |
| Web(Vue) | `vite.config.ts` | **修改** | 补充云同步端点的 dev proxy |
| Web(Vue) | `api/cloudClient.ts` | **修改** | 移除外部 `serverUrl` baseURL |
| Web(Vue) | `api/cloudSync.ts` | **修改** | 新增 `probeServerUrl()` 方法 |
| Web(Vue) | `components/CloudSyncSettingsDialog.vue` | **修改** | 删除 `probeReachability()`,改用 API 调用 |
| 层 | 文件 | 变更 | 说明 |
|---|---|---|---|
| MAUI | `MauiProgram.cs` | **无需修改** | 不注册 `AddCloudSyncServer()` |
| MAUI | `EmbeddedWebServerService.cs` | **无需修改** | 不映射 `MapCloudSyncEndpoints()` |
### 5. 端点路由变更汇总
| 路由 | 方法 | 变更 | 可用环境 |
|---|---|---|---|
| `/cloud-sync/probe` | POST | **新增** | HostVite dev proxy/ MAUI(同源请求到 Host |
| `/auth/login` | POST | 无变更 | 同上 |
| `/auth/step-up` | POST | 无变更 | 同上 |
| `/tasks/` | GET | 无变更 | 同上 |
| `/security/policy` | GET | 无变更 | 同上 |
### 6. 服务端/客户端职责边界总结
```
┌──────────────────────────────────────────────────────────────┐
│ Hua.Todo.Application (共享层) │
│ │
│ AddApplicationServices() AddCloudSyncServer() │
│ ├─ TodoDbContext ├─ CloudAuthService │
│ ├─ TaskRepository ├─ CloudAdminService │
│ ├─ TaskService ├─ CloudTaskSyncService │
│ ├─ DynamicApi ├─ SecurityPolicyService │
│ └─ DTOs (Models/*) ├─ CloudProbeService (新) │
│ ↑ 纯数据合约,两端安全 ├─ Authentication/Policy │
│ └─ MapCloudSyncEndpoints │
│ │
│ Host 注册: AddApplicationServices + AddCloudSyncServer │
│ MAUI 注册: AddApplicationServices only │
└──────────────────────────────────────────────────────────────┘
```
### 7. 安全考量
| 风险点 | 缓解措施 |
|---|---|
| SSRF(探测端点攻击内网) | `CloudProbeService` 限制目标 URL 必须是 HTTP/HTTPS 公网地址,禁止探测 localhost/内网 IP |
| 探测请求被滥用 | 加频率限制(如每分钟 3 次),仅允许同源请求 |
| MAUI 端不暴露云同步端点 | `AddCloudSyncServer()` 仅在 Host 调用,MAUI 无法访问云同步端点 |
+71
View File
@@ -0,0 +1,71 @@
param(
[switch]$Init,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Install-CodeGraph {
Write-Host "[信息] 正在安装 codegraph…" -ForegroundColor Cyan
$npm = Get-Command npm -ErrorAction SilentlyContinue
if ($npm) {
Write-Host "[信息] 通过 npm 安装 @colbymchenry/codegraph…" -ForegroundColor Cyan
npm install -g @colbymchenry/codegraph
if ($LASTEXITCODE -eq 0) {
Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green
return
}
Write-Host "[警告] npm 安装失败,尝试使用官方安装脚本…" -ForegroundColor Yellow
}
Write-Host "[信息] 通过官方脚本安装…" -ForegroundColor Cyan
irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
if ($LASTEXITCODE -ne 0) { throw "codegraph 安装失败" }
Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green
}
# 切换到上级目录(脚本所在目录的上级)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$parentDir = Split-Path -Parent $scriptDir
Push-Location $parentDir
Write-Host "[信息] 工作目录已切换到: $parentDir" -ForegroundColor Cyan
try {
$codegraph = Get-Command codegraph -ErrorAction Stop
Write-Host "[正常] 已检测到 codegraph CLI" -ForegroundColor Green
} catch {
Write-Host "[提示] 未找到 codegraph,将自动安装…" -ForegroundColor Yellow
Install-CodeGraph
}
if (-not (Test-Path ".\.codegraph")) {
Write-Host "[警告] 未找到 .codegraph/ 目录,正在执行初始化…" -ForegroundColor Yellow
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 初始化 + 索引构建完毕" -ForegroundColor Green
} elseif ($Init) {
Write-Host "[信息] 正在重新初始化…" -ForegroundColor Cyan
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 重新初始化 + 索引构建完毕" -ForegroundColor Green
} elseif ($Force) {
Write-Host "[信息] 正在执行全量重建索引…" -ForegroundColor Cyan
codegraph index --force
if ($LASTEXITCODE -ne 0) { throw "codegraph index --force 失败" }
Write-Host "[完成] 全量重建索引完毕" -ForegroundColor Green
} else {
Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan
codegraph sync
if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" }
Write-Host "[完成] 增量同步完毕" -ForegroundColor Green
}
Write-Host "`n--- CodeGraph 状态 ---" -ForegroundColor Cyan
codegraph status
if ($LASTEXITCODE -ne 0) {
Write-Host "[警告] codegraph status 返回异常" -ForegroundColor Yellow
}
Pop-Location
@@ -40,14 +40,5 @@ public static class ClaimsPrincipalExtensions
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase)); 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));
}
} }
@@ -10,9 +10,5 @@ public static class CloudClaims
/// </summary> /// </summary>
public const string Permission = "perm"; public const string Permission = "perm";
/// <summary>
/// 二次认证状态 Claim。
/// </summary>
public const string StepUp = "step_up";
} }
@@ -100,11 +100,6 @@ public class SessionAuthenticationHandler : AuthenticationHandler<Authentication
claims.Add(new Claim(CloudClaims.Permission, p)); 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 identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name); var ticket = new AuthenticationTicket(principal, Scheme.Name);
@@ -21,7 +21,7 @@ public static class CloudSyncEndpointExtensions
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth"); var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous(); auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization(); auth.MapPost("/logout", LogoutAsync).RequireAuthorization();
auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization(); auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
@@ -43,6 +43,10 @@ public static class CloudSyncEndpointExtensions
admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync); admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync);
admin.MapGet("/audit-logs", GetAuditLogsAsync); admin.MapGet("/audit-logs", GetAuditLogsAsync);
// 服务端探测端点(允许匿名访问,用于探测用户配置的外部服务端)
var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup");
probe.MapPost("/probe", ProbeAsync).AllowAnonymous();
return app; return app;
} }
@@ -95,33 +99,6 @@ public static class CloudSyncEndpointExtensions
return Results.Json(response); return Results.Json(response);
} }
private static async Task<IResult> StepUpAsync(
StepUpRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("Password is required.");
}
var (ip, ua) = GetClientInfo(httpContext);
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, ip, ua, cancellationToken);
if (!expiresAt.HasValue)
{
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
}
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
}
private static async Task<IResult> ChangePasswordAsync( private static async Task<IResult> ChangePasswordAsync(
ChangePasswordRequest request, ChangePasswordRequest request,
CloudAuthService authService, CloudAuthService authService,
@@ -154,6 +131,21 @@ public static class CloudSyncEndpointExtensions
return Results.Ok(); return Results.Ok();
} }
private static async Task<IResult> LogoutAsync(
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
await authService.LogoutAsync(sessionId.Value, cancellationToken);
return Results.Ok();
}
private static async Task<IResult> GetTasksAsync( private static async Task<IResult> GetTasksAsync(
CloudTaskSyncService taskService, CloudTaskSyncService taskService,
HttpContext httpContext, HttpContext httpContext,
@@ -191,11 +183,6 @@ public static class CloudSyncEndpointExtensions
return CloudApiErrors.Forbidden(); return CloudApiErrors.Forbidden();
} }
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken); var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
return Results.Json(response); return Results.Json(response);
} }
@@ -237,12 +224,7 @@ public static class CloudSyncEndpointExtensions
return CloudApiErrors.Forbidden(); return CloudApiErrors.Forbidden();
} }
if (!httpContext.User.HasStepUp()) var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, cancellationToken);
{
return CloudApiErrors.SecondFactorRequired();
}
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.SecondFactorExpiryMinutes, request.IsTrustedDeviceOnly, cancellationToken);
return Results.Json(policy); return Results.Json(policy);
} }
@@ -345,5 +327,19 @@ public static class CloudSyncEndpointExtensions
return Results.Json(logs); return Results.Json(logs);
} }
private static async Task<IResult> ProbeAsync(
ProbeRequest request,
CloudProbeService probeService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl))
{
return CloudApiErrors.BadRequest("TargetUrl is required.");
}
var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken);
return Results.Json(result);
}
#endregion #endregion
} }
@@ -29,6 +29,18 @@ public static class CloudSyncServiceCollectionExtensions
services.AddScoped<CloudTaskSyncService>(); services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>(); services.AddScoped<SecurityPolicyService>();
// 注册探测服务专用的 HttpClient(仅限服务端,MAUI 端不注册 AddCloudSyncServer
// 关闭 SSL 证书校验:探测目标可能是自签证书或内网服务器
services.AddHttpClient("ProbeClient", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddScoped<CloudProbeService>();
services.AddAuthentication(SessionAuthenticationDefaults.Scheme) services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { }); .AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
@@ -56,5 +56,4 @@ public class SessionDto
public string UserName { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; }
public DateTime ExpiresAtUtc { get; set; } public DateTime ExpiresAtUtc { get; set; }
public bool IsSteppedUp { get; set; }
} }
@@ -100,25 +100,5 @@ public class ChangePasswordRequest
public string NewPassword { get; set; } = string.Empty; public string NewPassword { 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; }
}
@@ -33,12 +33,6 @@ public static class CloudApiErrors
public static IResult Forbidden(string message = "Forbidden.") public static IResult Forbidden(string message = "Forbidden.")
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message); => 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>
/// 请求非法。 /// 请求非法。
/// </summary> /// </summary>
@@ -0,0 +1,48 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 服务端探测请求。
/// </summary>
public class ProbeRequest
{
/// <summary>
/// 目标 URL。
/// </summary>
public string TargetUrl { get; set; } = string.Empty;
}
/// <summary>
/// 服务端探测响应。
/// </summary>
public class ProbeResponse
{
/// <summary>
/// 是否可达。
/// </summary>
public bool IsReachable { get; set; }
/// <summary>
/// HTTP 状态码(如果探测失败则为 null)。
/// </summary>
public int? HttpStatus { get; set; }
/// <summary>
/// 是否使用 HTTPS。
/// </summary>
public bool IsHttps { get; set; }
/// <summary>
/// 页面标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 描述信息。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 结果类型:success | warn | error。
/// </summary>
public string Type { get; set; } = "error";
}
@@ -15,16 +15,6 @@ public class SecurityPolicyDto
/// </summary> /// </summary>
public bool AllowSync { get; set; } public bool AllowSync { get; set; }
/// <summary>
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary> /// <summary>
/// 是否仅限受信任设备。 /// 是否仅限受信任设备。
/// </summary> /// </summary>
@@ -46,11 +36,6 @@ public class UpdateSecurityPolicyRequest
/// </summary> /// </summary>
public bool AllowSync { get; set; } public bool AllowSync { get; set; }
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary> /// <summary>
/// 是否仅限受信任设备。 /// 是否仅限受信任设备。
/// </summary> /// </summary>
@@ -58,7 +58,7 @@ public class CloudAdminService
_dbContext.Users.Add(user); _dbContext.Users.Add(user);
// 为新用户创建默认策略 // 为新用户创建默认策略
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id }); _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true });
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
@@ -103,8 +103,7 @@ public class CloudAdminService
UserId = s.UserId, UserId = s.UserId,
UserName = s.User != null ? s.User.UserName : "Unknown", UserName = s.User != null ? s.User.UserName : "Unknown",
CreatedAtUtc = s.CreatedAtUtc, CreatedAtUtc = s.CreatedAtUtc,
ExpiresAtUtc = s.ExpiresAtUtc, ExpiresAtUtc = s.ExpiresAtUtc
IsSteppedUp = s.StepUpExpiresAtUtc.HasValue && s.StepUpExpiresAtUtc.Value > now
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -138,7 +138,7 @@ public class CloudAuthService
user.PasswordHash = _passwordHasher.HashPassword(user, generatedPassword); user.PasswordHash = _passwordHasher.HashPassword(user, generatedPassword);
_dbContext.Users.Add(user); _dbContext.Users.Add(user);
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true });
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
@@ -197,7 +197,7 @@ public class CloudAuthService
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (policy == null) if (policy == null)
{ {
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }; policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true };
_dbContext.SecurityPolicies.Add(policy); _dbContext.SecurityPolicies.Add(policy);
} }
@@ -217,55 +217,6 @@ public class CloudAuthService
} }
/// <summary> /// <summary>
/// 通过再输入口令提升会话权限(step-up)。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
await LogAsync("StepUpFailed", "Invalid password during step-up.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return null;
}
var policy = await _dbContext.SecurityPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
var expiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30;
if (expiryMinutes <= 0) expiryMinutes = 30;
var stepUpExpiresAt = now.AddMinutes(expiryMinutes);
session.StepUpExpiresAtUtc = stepUpExpiresAt;
await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("StepUpSuccess", $"Session stepped up for {expiryMinutes} minutes.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return stepUpExpiresAt;
}
/// <summary> /// <summary>
/// 修改用户密码。 /// 修改用户密码。
/// </summary> /// </summary>
@@ -317,5 +268,21 @@ public class CloudAuthService
await LogAsync("ChangePasswordSuccess", "Password changed successfully.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); await LogAsync("ChangePasswordSuccess", "Password changed successfully.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return true; return true;
} }
/// <summary>
/// 登出:从数据库移除当前会话,使 Token 立即失效。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否成功移除。</returns>
public async Task<bool> LogoutAsync(Guid sessionId, CancellationToken cancellationToken)
{
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null) return false;
_dbContext.UserSessions.Remove(session);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
} }
@@ -0,0 +1,135 @@
using System.Net;
using System.Net.Http;
using Hua.Todo.Application.CloudSync.Models;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。
/// </summary>
public class CloudProbeService
{
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// 创建 <see cref="CloudProbeService"/>。
/// </summary>
/// <param name="httpClientFactory">HTTP 客户端工厂。</param>
public CloudProbeService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// 探测目标 URL 的可达性。
/// </summary>
/// <param name="targetUrl">目标 URL。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>探测响应。</returns>
public async Task<ProbeResponse> ProbeAsync(string targetUrl, CancellationToken cancellationToken)
{
var response = new ProbeResponse();
if (string.IsNullOrWhiteSpace(targetUrl))
{
response.Type = "error";
response.Description = "目标地址不能为空。";
return response;
}
Uri uri;
try
{
uri = new Uri(targetUrl);
}
catch
{
response.Type = "error";
response.Description = "目标地址格式不正确,请包含协议(http:// 或 https://)。";
return response;
}
if (uri.Scheme != "http" && uri.Scheme != "https")
{
response.Type = "error";
response.Description = "仅支持 http:// 或 https:// 地址。";
return response;
}
response.IsHttps = uri.Scheme == "https";
// 构造探测 URL:使用 {origin}/api 而非根路径,避免因根路由缺失返回 404
var probeUri = new Uri(uri, "/api");
try
{
var client = _httpClientFactory.CreateClient("ProbeClient");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(10));
using var request = new HttpRequestMessage(HttpMethod.Get, probeUri);
request.Headers.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
using var httpResponse = await client.SendAsync(request, cts.Token);
response.IsReachable = true;
response.HttpStatus = (int)httpResponse.StatusCode;
if (httpResponse.IsSuccessStatusCode)
{
if (!response.IsHttps)
{
response.Type = "warn";
response.Title = "可达(建议 HTTPS";
response.Description = $"服务器响应 HTTP {response.HttpStatus},当前使用明文传输,建议切换为 HTTPS。";
}
else
{
response.Type = "success";
response.Title = "可达";
response.Description = $"服务器响应 HTTP {response.HttpStatus},连接正常,可继续登录。";
}
}
else
{
// 非 2xx 也说明服务器可达,仅作为提示
if (!response.IsHttps)
{
response.Type = "warn";
response.Title = "可达(建议 HTTPS";
response.Description = $"服务器响应 HTTP {response.HttpStatus}(非成功状态码),当前使用明文传输,建议切换为 HTTPS。";
}
else
{
response.Type = "success";
response.Title = "可达";
response.Description = $"服务器响应 HTTP {response.HttpStatus}(非成功状态码),但连接正常,可继续登录。";
}
}
}
catch (OperationCanceledException)
{
response.IsReachable = false;
response.Type = response.IsHttps ? "error" : "warn";
response.Title = "存在风险";
response.Description = "探测超时(网络不可达或目标无响应)。";
}
catch (HttpRequestException ex)
{
response.IsReachable = false;
response.Type = response.IsHttps ? "error" : "warn";
response.Title = "存在风险";
response.Description = response.IsHttps
? $"探测失败:{ex.Message}"
: $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。";
}
catch (Exception)
{
response.IsReachable = false;
response.Type = "error";
response.Description = "探测过程中发生未知错误。";
}
return response;
}
}
@@ -64,45 +64,118 @@ public class CloudTaskSyncService
if (request.Upserts.Count > 0) if (request.Upserts.Count > 0)
{ {
foreach (var upsert in request.Upserts) // 去重:同一 Id 保留最后一条(后发送覆盖先发送)
var uniqueUpserts = request.Upserts
.GroupBy(u => u.Id)
.Select(g => g.Last())
.Where(u => !string.IsNullOrWhiteSpace(u.Title))
.ToList();
// 预先查询该用户所有已存在的任务 ID
var existingIds = await _dbContext.Tasks
.Where(t => t.UserId == userId)
.Select(t => t.Id)
.ToHashSetAsync(cancellationToken);
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射)
var clientIdToEntity = new Dictionary<int, TaskEntity>();
// 第一遍:处理根任务(ParentTaskId == null
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
{ {
if (string.IsNullOrWhiteSpace(upsert.Title)) TaskEntity? entity = null;
{
continue;
}
if (upsert.Id.HasValue) if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value))
{ {
var existing = await _dbContext.Tasks // 更新已存在任务
entity = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken); .FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (entity != null)
if (existing != null)
{ {
existing.Title = upsert.Title.Trim(); entity.Title = upsert.Title.Trim();
existing.Priority = upsert.Priority; entity.Priority = upsert.Priority;
existing.IsCompleted = upsert.IsCompleted; entity.IsCompleted = upsert.IsCompleted;
existing.ParentTaskId = upsert.ParentTaskId; entity.ParentTaskId = null;
existing.UpdatedAt = DateTime.UtcNow; entity.UpdatedAt = DateTime.UtcNow;
continue;
} }
} }
var entity = new TaskEntity if (entity == null)
{ {
UserId = userId, // 新建任务;EF Core 会自动分配新的服务器 ID
Title = upsert.Title.Trim(), entity = new TaskEntity
Priority = upsert.Priority, {
IsCompleted = upsert.IsCompleted, UserId = userId,
CreatedAt = DateTime.UtcNow, Title = upsert.Title.Trim(),
UpdatedAt = DateTime.UtcNow, Priority = upsert.Priority,
ParentTaskId = upsert.ParentTaskId IsCompleted = upsert.IsCompleted,
}; ParentTaskId = null,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_dbContext.Tasks.Add(entity);
_dbContext.Tasks.Add(entity); if (upsert.Id.HasValue)
{
clientIdToEntity[upsert.Id.Value] = entity;
}
}
} }
}
await _dbContext.SaveChangesAsync(cancellationToken); // 保存根任务,获取服务器分配的 ID
await _dbContext.SaveChangesAsync(cancellationToken);
// 第二遍:处理子任务(ParentTaskId != null
foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue))
{
// 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID
int? parentId = upsert.ParentTaskId;
if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity))
{
parentId = parentEntity.Id;
}
TaskEntity? entity = null;
if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value))
{
// 更新已存在任务
entity = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (entity != null)
{
entity.Title = upsert.Title.Trim();
entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted;
entity.ParentTaskId = parentId;
entity.UpdatedAt = DateTime.UtcNow;
}
}
if (entity == null)
{
// 新建子任务
entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
ParentTaskId = parentId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_dbContext.Tasks.Add(entity);
if (upsert.Id.HasValue)
{
clientIdToEntity[upsert.Id.Value] = entity;
}
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
return new SyncResponse return new SyncResponse
{ {
@@ -37,8 +37,6 @@ public class SecurityPolicyService
{ {
AllowPersist = policy?.AllowPersist ?? true, AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true, AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
}; };
} }
@@ -49,11 +47,10 @@ public class SecurityPolicyService
/// <param name="userId">用户 ID。</param> /// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param> /// <param name="allowPersist">是否允许落盘。</param>
/// <param name="allowSync">是否允许同步。</param> /// <param name="allowSync">是否允许同步。</param>
/// <param name="secondFactorExpiryMinutes">二次认证有效期。</param>
/// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param> /// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param>
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns> /// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, int secondFactorExpiryMinutes, bool isTrustedDeviceOnly, CancellationToken cancellationToken) public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, bool isTrustedDeviceOnly, CancellationToken cancellationToken)
{ {
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
if (policy == null) if (policy == null)
@@ -64,7 +61,6 @@ public class SecurityPolicyService
UserId = userId, UserId = userId,
AllowPersist = allowPersist, AllowPersist = allowPersist,
AllowSync = allowSync, AllowSync = allowSync,
SecondFactorExpiryMinutes = secondFactorExpiryMinutes,
IsTrustedDeviceOnly = isTrustedDeviceOnly IsTrustedDeviceOnly = isTrustedDeviceOnly
}; };
_dbContext.SecurityPolicies.Add(policy); _dbContext.SecurityPolicies.Add(policy);
@@ -73,7 +69,6 @@ public class SecurityPolicyService
{ {
policy.AllowPersist = allowPersist; policy.AllowPersist = allowPersist;
policy.AllowSync = allowSync; policy.AllowSync = allowSync;
policy.SecondFactorExpiryMinutes = secondFactorExpiryMinutes;
policy.IsTrustedDeviceOnly = isTrustedDeviceOnly; policy.IsTrustedDeviceOnly = isTrustedDeviceOnly;
} }
@@ -68,6 +68,15 @@ public class DynamicApiMiddleware
var path = context.Request.Path.Value ?? string.Empty; var path = context.Request.Path.Value ?? string.Empty;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
// 处理 /api 根路径探测请求:返回 200 表示服务在线
if (segments.Length == 1 && segments[0] == "api")
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = 200;
await context.Response.WriteAsync("{\"status\":\"ok\"}");
return;
}
if (segments.Length < 2 || segments[0] != "api") if (segments.Length < 2 || segments[0] != "api")
{ {
await _next(context); await _next(context);
@@ -104,11 +104,6 @@
</template> </template>
<el-table :data="sessions" v-loading="loadingSessions" style="width: 100%"> <el-table :data="sessions" v-loading="loadingSessions" style="width: 100%">
<el-table-column prop="userName" label="用户名" width="150"></el-table-column> <el-table-column prop="userName" label="用户名" width="150"></el-table-column>
<el-table-column prop="isSteppedUp" label="已二次认证" width="120">
<template #default="scope">
<el-tag :type="scope.row.isSteppedUp ? 'success' : 'info'">{{ scope.row.isSteppedUp ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="expiresAtUtc" label="过期时间" width="200"> <el-table-column prop="expiresAtUtc" label="过期时间" width="200">
<template #default="scope">{{ formatDate(scope.row.expiresAtUtc) }}</template> <template #default="scope">{{ formatDate(scope.row.expiresAtUtc) }}</template>
</el-table-column> </el-table-column>
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"WebServer": { "WebServer": {
"Port": 5057, "Port": 5057,
"IsUsingStatic": true, "IsUsingStatic": false,
"ConnectionString": "", "ConnectionString": "",
"HostUrl": "http://localhost:5057", "HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5057" "ForEndUrl": "http://localhost:5174"
}, },
"Development": { "Development": {
}, },
+2
View File
@@ -0,0 +1,2 @@
# Dev 模式:API 与云同步共用本地后端代理目标
VITE_API_TARGET=http://localhost:5173
+2
View File
@@ -0,0 +1,2 @@
# MAUI 构建:云同步远端地址
VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com
+2
View File
@@ -0,0 +1,2 @@
# Production 构建:云同步远端地址(proxy 在 build 时无效,此变量可由其他构建逻辑使用)
VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com
+22 -13
View File
@@ -1,6 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import CloudSyncStorage from '../services/cloudSyncStorage'; import CloudSyncStorage from '../services/cloudSyncStorage';
import { cloudSyncState } from '../services/cloudSyncState';
const showNotification = (message: string, type: 'error' | 'success' = 'error') => { const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) { if ((window as any).showToast) {
@@ -33,10 +32,17 @@ const cloudClient = axios.create({
}); });
cloudClient.interceptors.request.use((config) => { cloudClient.interceptors.request.use((config) => {
const { serverUrl } = CloudSyncStorage.loadSettings(); // 根据运行模式决定 baseURL
if (serverUrl) { // - Host 模式(Vite dev):使用同源请求,通过 Vite proxy 到 Host
config.baseURL = serverUrl; // - MAUI 模式:使用配置的 serverUrl 连接外部云同步服务器
const isMaui = (window as any).__IS_MAUI__ === true;
if (isMaui) {
const { serverUrl } = CloudSyncStorage.loadSettings();
if (serverUrl) {
config.baseURL = serverUrl;
}
} }
// Host 模式下不设置 baseURLaxios 使用浏览器默认同源
const session = CloudSyncStorage.loadSession(); const session = CloudSyncStorage.loadSession();
if (session?.accessToken) { if (session?.accessToken) {
@@ -53,20 +59,23 @@ cloudClient.interceptors.response.use(
let errorMessage = '网络错误,请稍后再试'; let errorMessage = '网络错误,请稍后再试';
const status = error?.response?.status as number | undefined; const status = error?.response?.status as number | undefined;
const errorCode = error?.response?.data?.code as string | undefined; const data = error?.response?.data as any;
const errorCode: string | undefined = data?.code ?? data?.Code;
if (status === 403 && errorCode === 'SECOND_FACTOR_REQUIRED') { if (status === 403 && errorCode === 'FORBIDDEN') {
// 捕获“需要二次认证”错误,触发二次认证弹窗,并中断当前请求(不弹普通错误提示) // 缺少同步权限(例如 AllowSync = false):提示用户检查权限配置
cloudSyncState.isSteppingUp = true; showNotification('云同步权限不足:请确认安全策略中已启用 AllowSync', 'error');
// 注意:这里我们暂不自动重试,让用户在弹窗输入密码后再手动触发(或由 UI 层处理重试)
return Promise.reject(error); return Promise.reject(error);
} }
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
CloudSyncStorage.clearSession(); if (status === 401) {
requestCloudSyncReLogin(); CloudSyncStorage.clearSession();
errorMessage = status === 401 ? '云同步会话已过期,请重新登录' : '云同步权限不足,请重新登录或联系管理员'; requestCloudSyncReLogin();
showNotification(errorMessage, 'error'); showNotification('云同步会话已过期,请重新登录', 'error');
} else {
showNotification(`云同步请求被拒绝(${errorCode || status}`, 'error');
}
return Promise.reject(error); return Promise.reject(error);
} }
+96 -12
View File
@@ -25,18 +25,49 @@ export interface CloudTaskItem {
parentTaskId?: number | null; parentTaskId?: number | null;
} }
/**
* 同步请求中的任务 Upsert DTO(用于新增或更新任务)。
* 注意:id 为 null/undefined 表示新建任务,由服务端分配 ID。
*/
export interface CloudTaskUpsert {
id?: number | null;
title: string;
priority: 0 | 1 | 2;
isCompleted: boolean;
parentTaskId?: number | null;
}
/**
* 同步请求体(包含新增/更新和删除列表)。
*/
export interface CloudSyncRequest {
upserts: CloudTaskUpsert[];
deletes: number[];
}
/**
* 同步响应体(返回服务端最新全量任务列表)。
*/
export interface CloudSyncResponse {
serverTimeUtc: string;
tasks: CloudTaskItem[];
}
export interface SecurityPolicyResponse { export interface SecurityPolicyResponse {
allowPersist: boolean; allowPersist: boolean;
allowSync: boolean; allowSync: boolean;
requireSecondFactorFor: string[];
} }
export interface StepUpRequest { /**
password: string; * 服务端探测响应。
} */
export interface ProbeServerUrlResponse {
export interface StepUpResponse { isReachable: boolean;
stepUpExpiresAtUtc: string; httpStatus: number | null;
isHttps: boolean;
title: string;
description: string;
type: 'success' | 'warn' | 'error';
} }
/** /**
@@ -70,6 +101,37 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
return roots; return roots;
}; };
/**
* 把前端的任务树结构扁平化为 CloudTaskUpsert 列表,用于发送到云端。
* - id > 0 表示已同步到云端的任务(保留 id 用于更新)
* - id <= 0 或未设置表示本地新建任务(id 置为 null,由服务端分配新 ID)
* - 使用 seen 集合去重:同一任务(按 id 或 hash)只发送一次
*/
export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => {
const result: CloudTaskUpsert[] = [];
const seenIds = new Set<number | null>();
const walk = (items: Task[]) => {
for (const task of items) {
const id = task.id > 0 ? task.id : null;
// 去重:同一 id 只保留第一次出现
if (id != null && seenIds.has(id)) continue;
if (id != null) seenIds.add(id);
result.push({
id,
title: task.title,
priority: task.priority as 0 | 1 | 2,
isCompleted: Boolean(task.isCompleted),
parentTaskId: typeof task.parentTaskId === 'number' && task.parentTaskId > 0 ? task.parentTaskId : null,
});
if (task.subTasks && task.subTasks.length > 0) {
walk(task.subTasks);
}
}
};
walk(tasks);
return result;
};
/** /**
* 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。 * 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。
*/ */
@@ -93,9 +155,14 @@ export const cloudSyncApi = {
}, },
/** /**
* 清空云同步会话(登出) * 登出:通知服务端撤销会话并清空本地 Token
*/ */
logout(): void { async logout(): Promise<void> {
try {
await cloudClient.post('/auth/logout');
} catch {
// 即使服务端请求失败也清空本地,避免残留无效 Token
}
CloudSyncStorage.clearSession(); CloudSyncStorage.clearSession();
}, },
@@ -108,6 +175,22 @@ export const cloudSyncApi = {
return buildTaskTreeFromCloudItems(items); return buildTaskTreeFromCloudItems(items);
}, },
/**
* 同步本地任务到云端(批量 upsert + delete),并返回云端最新任务列表和服务器时间。
* 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。
* 注意:该端点需要已登录 + 二次认证(step-up)。
*/
async syncTasks(localTasks: Task[], deleteIds: number[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> {
const upserts = flattenTasksToCloudUpserts(localTasks);
const body: CloudSyncRequest = { upserts, deletes: deleteIds };
const response = await cloudClient.post<CloudSyncResponse>('/sync/', body);
const items = Array.isArray(response.data?.tasks) ? response.data.tasks : [];
return {
tasks: buildTaskTreeFromCloudItems(items),
serverTimeUtc: response.data?.serverTimeUtc,
};
},
/** /**
* 获取当前用户的安全策略。 * 获取当前用户的安全策略。
*/ */
@@ -117,10 +200,11 @@ export const cloudSyncApi = {
}, },
/** /**
* 执行二次认证(Step-up * 通过 Host 服务端探测外部服务端可达性
* 探测请求从 Host 发起,避免浏览器直连的 CORS/证书/网络限制。
*/ */
async stepUp(password: string): Promise<StepUpResponse> { async probeServerUrl(targetUrl: string): Promise<ProbeServerUrlResponse> {
const response = await cloudClient.post<StepUpResponse>('/auth/step-up', { password }); const response = await cloudClient.post<ProbeServerUrlResponse>('/cloud-sync/probe', { targetUrl });
return response.data; return response.data;
}, },
}; };
+8 -1
View File
@@ -12,6 +12,7 @@ export const taskApi = {
async getTasks(completed?: boolean): Promise<ApiResponse<Task[]>> { async getTasks(completed?: boolean): Promise<ApiResponse<Task[]>> {
if (!LocalStorageService.isOnline()) { if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks(); const localTasks = LocalStorageService.loadTasks();
LocalStorageService.syncTaskIdCounter(localTasks);
return { return {
success: true, success: true,
data: completed !== undefined data: completed !== undefined
@@ -50,6 +51,8 @@ export const taskApi = {
syncStatus.isOnline = true; syncStatus.isOnline = true;
syncStatus.pendingChanges = 0; syncStatus.pendingChanges = 0;
LocalStorageService.saveSyncStatus(syncStatus); LocalStorageService.saveSyncStatus(syncStatus);
// 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突
LocalStorageService.syncTaskIdCounter(apiResponse.data);
} }
return apiResponse; return apiResponse;
@@ -100,7 +103,7 @@ export const taskApi = {
*/ */
async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> { async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> {
const newTask: Task = { const newTask: Task = {
id: Date.now(), id: LocalStorageService.getNextTaskId(),
title: dto.title, title: dto.title,
priority: dto.priority, priority: dto.priority,
isCompleted: false, isCompleted: false,
@@ -151,6 +154,8 @@ export const taskApi = {
localTasks.unshift(apiResponse.data); localTasks.unshift(apiResponse.data);
} }
LocalStorageService.saveTasks(localTasks); LocalStorageService.saveTasks(localTasks);
// 用后端返回的真实 ID 同步计数器,保证后续新建任务 ID 始终大于已有任务
LocalStorageService.syncTaskIdCounter(localTasks);
} }
return apiResponse; return apiResponse;
@@ -386,6 +391,8 @@ export const taskApi = {
syncStatus.isOnline = true; syncStatus.isOnline = true;
syncStatus.pendingChanges = 0; syncStatus.pendingChanges = 0;
LocalStorageService.saveSyncStatus(syncStatus); LocalStorageService.saveSyncStatus(syncStatus);
// 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突
LocalStorageService.syncTaskIdCounter(apiResponse.data);
return { return {
success: true, success: true,
@@ -11,10 +11,10 @@
<div class="section-title">同步开关</div> <div class="section-title">同步开关</div>
<label class="checkbox-row"> <label class="checkbox-row">
<input type="checkbox" v-model="localEnabled" @change="onToggleEnabled" /> <input type="checkbox" v-model="localEnabled" @change="onToggleEnabled" />
<span>启用云同步v1.2.0仅支持登录后拉取任务</span> <span>启用云同步支持双向同步</span>
</label> </label>
<div class="hint"> <div class="hint">
启用后主界面将以云端任务为准并进入只读展示本地编辑/新增将在后续版本补齐 启用后任务将与云端同步支持双向同步功能
</div> </div>
</div> </div>
@@ -47,6 +47,29 @@
<div class="hint"> <div class="hint">
已登录<span class="mono">{{ sessionSummary }}</span> 已登录<span class="mono">{{ sessionSummary }}</span>
</div> </div>
<!-- 同步状态 -->
<div class="sync-status">
<div class="sync-status-item">
<span class="label">连接状态:</span>
<span :class="syncStatus.isOnline ? 'success' : 'offline'">
{{ syncStatus.isOnline ? '在线' : '离线' }}
</span>
</div>
<div class="sync-status-item">
<span class="label">上次同步:</span>
<span class="mono">
{{ syncStatus.lastSyncTime > 0 ? formatSyncTime(syncStatus.lastSyncTime) : '从未同步' }}
</span>
</div>
<div class="sync-status-item">
<span class="label">待同步:</span>
<span :class="syncStatus.pendingChanges > 0 ? 'warn' : ''">
{{ syncStatus.pendingChanges }} 项变更
</span>
</div>
</div>
<div v-if="policy" class="policy-info"> <div v-if="policy" class="policy-info">
<div class="policy-item"> <div class="policy-item">
<span class="label">落盘策略:</span> <span class="label">落盘策略:</span>
@@ -63,8 +86,9 @@
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="btn btn-secondary" @click="logout">登出</button> <button class="btn btn-secondary" @click="logout">登出</button>
<button class="btn btn-primary" :disabled="!localEnabled" @click="pullTasks"> <button class="btn btn-outline" :disabled="!canSync || isSyncing" @click="syncNow">
拉取任务 <span v-if="isSyncing">同步中...</span>
<span v-else>立即同步</span>
</button> </button>
</div> </div>
</div> </div>
@@ -100,99 +124,186 @@
<button class="btn btn-secondary" @click="close">关闭</button> <button class="btn btn-secondary" @click="close">关闭</button>
</div> </div>
</div> </div>
<!-- Step-up (2FA) Overlay -->
<div v-if="cloudSyncState.isSteppingUp" class="overlay step-up-overlay" @click.self="cancelStepUp">
<div class="dialog step-up-dialog">
<div class="dialog-header">
<h2>🔐 二次认证</h2>
</div>
<div class="dialog-body">
<div class="hint">当前操作属于高风险操作请验证您的登录密码</div>
<div class="form-row">
<input
v-model="stepUpPassword"
class="text-input"
type="password"
placeholder="请输入密码"
@keyup.enter="confirmStepUp"
/>
</div>
<div v-if="stepUpError" class="error-text">{{ stepUpError }}</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="cancelStepUp">取消</button>
<button class="btn btn-primary" :disabled="!stepUpPassword || isSteppingUp" @click="confirmStepUp">
{{ isSteppingUp ? '验证中...' : '确认' }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/**
* 云同步设置对话框组件
*
* 提供云同步功能的配置界面,包括:
* - 启用/禁用云同步开关
* - 配置和探测服务端地址
* - 用户登录/登出
* - 查看安全策略(落盘策略、同步权限)
* - 二次认证(Step-up Authentication
* - 拉取云端任务
*
* 与后端交互通过 cloudSyncApi 完成,状态通过 CloudSyncStorage 持久化。
* 通过 CustomEvent 与其他组件通信(cloudSyncStateChanged、cloudSyncPullTasksRequested)。
*/
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import CloudSyncStorage from '../services/cloudSyncStorage'; import CloudSyncStorage from '../services/cloudSyncStorage';
import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync'; import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync';
import LocalStorageService from '../services/localStorageService'; import LocalStorageService, { type SyncStatus } from '../services/localStorageService';
import { cloudSyncState } from '../services/cloudSyncState'; import { cloudSyncState } from '../services/cloudSyncState';
/** 探测结果的类型 */
type ProbeType = 'success' | 'warn' | 'error'; type ProbeType = 'success' | 'warn' | 'error';
/** 服务端探测结果 */
interface ProbeResult { interface ProbeResult {
/** 结果类型:成功/警告/错误 */
type: ProbeType; type: ProbeType;
/** 结果标题 */
title: string; title: string;
/** 结果详细描述 */
desc: string; desc: string;
} }
// ========== UI 状态 ==========
/** 对话框是否打开 */
const isOpen = ref(false); const isOpen = ref(false);
/** 云同步是否启用(本地状态) */
const localEnabled = ref(false); const localEnabled = ref(false);
/** 服务端地址输入框内容 */
const serverUrlInput = ref(''); const serverUrlInput = ref('');
/** 已保存的服务端地址 */
const serverUrlSaved = ref(''); const serverUrlSaved = ref('');
/** 是否正在探测服务端 */
const isProbing = ref(false); const isProbing = ref(false);
/** 探测结果 */
const probeResult = ref<ProbeResult | null>(null); const probeResult = ref<ProbeResult | null>(null);
// ========== 登录状态 ==========
/** 登录用户名 */
const userName = ref(''); const userName = ref('');
/** 登录密码 */
const password = ref(''); const password = ref('');
/** 是否正在登录中 */
const isLoggingIn = ref(false); const isLoggingIn = ref(false);
/** 安全策略(从服务端获取) */
const policy = ref<SecurityPolicyResponse | null>(null); const policy = ref<SecurityPolicyResponse | null>(null);
const stepUpPassword = ref(''); // ========== 同步状态 ==========
const stepUpError = ref('');
const isSteppingUp = ref(false);
/** 本地同步状态 */
const syncStatus = ref<SyncStatus>({
lastSyncTime: 0,
isOnline: false,
pendingChanges: 0,
});
/** 是否正在同步中 */
const isSyncing = ref(false);
/** 是否可以执行同步(需已启用云同步、已登录,且同步权限未被禁用) */
const canSync = computed(() => {
return localEnabled.value && isLoggedIn.value && policy.value?.allowSync !== false;
});
// ========== 核心方法 ==========
/**
* 从本地存储加载设置状态
* 并在已登录时自动获取安全策略和同步状态
*/
const loadLocalState = () => { const loadLocalState = () => {
refreshSession();
const settings = CloudSyncStorage.loadSettings(); const settings = CloudSyncStorage.loadSettings();
localEnabled.value = settings.enabled; localEnabled.value = settings.enabled;
serverUrlSaved.value = settings.serverUrl; serverUrlSaved.value = settings.serverUrl;
serverUrlInput.value = settings.serverUrl; serverUrlInput.value = settings.serverUrl;
refreshSyncStatus(); // 加载同步状态
if (isLoggedIn.value) { if (isLoggedIn.value) {
refreshPolicy(); refreshPolicy();
} }
}; };
/**
* 刷新安全策略
* 从服务端获取最新的安全策略,并同步到 cloudSyncState 和 LocalStorageService
*
* @throws 获取失败时输出错误日志,不中断流程
*/
const refreshPolicy = async () => { const refreshPolicy = async () => {
try { try {
const p = await cloudSyncApi.getSecurityPolicy(); const p = await cloudSyncApi.getSecurityPolicy();
policy.value = p; policy.value = p;
cloudSyncState.policy = p; cloudSyncState.policy = p;
// 更新落盘策略 // 更新落盘策略:控制是否允许本地持久化存储
LocalStorageService.setAllowPersist(p.allowPersist); LocalStorageService.setAllowPersist(p.allowPersist);
} catch (err) { } catch (err) {
console.error('Failed to fetch security policy:', err); console.error('Failed to fetch security policy:', err);
} }
}; };
const isLoggedIn = computed(() => Boolean(CloudSyncStorage.loadSession()?.accessToken)); /**
const sessionSummary = computed(() => { * 刷新同步状态
const s = CloudSyncStorage.loadSession(); * 从 LocalStorageService 加载最新的同步状态
if (!s) return ''; */
const shortUserId = s.userId ? s.userId.slice(0, 8) : ''; const refreshSyncStatus = () => {
return `${s.role || 'User'}@${shortUserId}`; syncStatus.value = LocalStorageService.loadSyncStatus();
}); };
/**
* 格式化同步时间
* 显示友好格式:刚刚、几分钟前、几小时前、具体时间
*
* @param timestamp - 时间戳(毫秒)
* @returns 格式化后的时间字符串
*/
const formatSyncTime = (timestamp: number): string => {
if (!timestamp) return '从未同步';
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
const date = new Date(timestamp);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
/** 是否已登录 */
const isLoggedIn = ref(false);
/** 已登录会话的摘要信息(格式:Role@UserId前8位) */
const sessionSummary = ref('');
/** 刷新会话状态(从 sessionStorage 同步到响应式 ref */
const refreshSession = () => {
const s = CloudSyncStorage.loadSession();
isLoggedIn.value = Boolean(s?.accessToken);
sessionSummary.value = s ? `${s.role || 'User'}@${s.userId ? s.userId.slice(0, 8) : ''}` : '';
};
/** 是否可以执行登录(需满足:已启用云同步、已保存服务端地址、用户名密码非空、未在登录中) */
const canLogin = computed(() => { const canLogin = computed(() => {
if (!localEnabled.value) return false; if (!localEnabled.value) return false;
if (!serverUrlSaved.value) return false; if (!serverUrlSaved.value) return false;
@@ -200,6 +311,13 @@ const canLogin = computed(() => {
return !isLoggingIn.value; return !isLoggingIn.value;
}); });
/**
* 显示 Toast 消息
* 优先使用全局 showToast 函数,降级使用 window.alert
*
* @param message - 消息内容
* @param type - 消息类型:error 或 success
*/
const showToast = (message: string, type: 'error' | 'success' = 'error') => { const showToast = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) { if ((window as any).showToast) {
(window as any).showToast(message, type); (window as any).showToast(message, type);
@@ -208,6 +326,13 @@ const showToast = (message: string, type: 'error' | 'success' = 'error') => {
} }
}; };
/**
* 规范化服务端地址
* 校验协议、主机名,并移除路径部分(仅保留 origin)
*
* @param raw - 用户输入的原始地址
* @returns 规范化结果:{ ok, normalized?, message?, warn? }
*/
const normalizeServerUrl = (raw: string): { ok: boolean; normalized?: string; message?: string; warn?: string } => { const normalizeServerUrl = (raw: string): { ok: boolean; normalized?: string; message?: string; warn?: string } => {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return { ok: false, message: '请输入服务端地址' }; if (!trimmed) return { ok: false, message: '请输入服务端地址' };
@@ -232,53 +357,10 @@ const normalizeServerUrl = (raw: string): { ok: boolean; normalized?: string; me
return { ok: true, normalized: origin.replace(/\/+$/, ''), warn: pathWarn }; 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 emitCloudSyncStateChanged = () => {
const settings = CloudSyncStorage.loadSettings(); const settings = CloudSyncStorage.loadSettings();
const session = CloudSyncStorage.loadSession(); const session = CloudSyncStorage.loadSession();
@@ -292,6 +374,12 @@ const emitCloudSyncStateChanged = () => {
window.dispatchEvent(event); window.dispatchEvent(event);
}; };
/**
* 切换云同步启用状态
* 启用前会校验是否已配置服务端地址
*
* @fires cloudSyncStateChanged - 状态变更后触发
*/
const onToggleEnabled = () => { const onToggleEnabled = () => {
if (localEnabled.value) { if (localEnabled.value) {
if (!serverUrlSaved.value) { if (!serverUrlSaved.value) {
@@ -308,6 +396,12 @@ const onToggleEnabled = () => {
emitCloudSyncStateChanged(); emitCloudSyncStateChanged();
}; };
/**
* 保存服务端地址并探测连接
* 会先规范化地址,保存到本地存储,然后调用后端探测接口
*
* @fires cloudSyncStateChanged - 保存后触发
*/
const saveServerUrl = async () => { const saveServerUrl = async () => {
const parsed = normalizeServerUrl(serverUrlInput.value); const parsed = normalizeServerUrl(serverUrlInput.value);
if (!parsed.ok || !parsed.normalized) { if (!parsed.ok || !parsed.normalized) {
@@ -329,7 +423,19 @@ const saveServerUrl = async () => {
isProbing.value = true; isProbing.value = true;
try { try {
probeResult.value = await probeReachability(serverUrlSaved.value); // 调用 Host 的探测接口,由服务端发起探测(避免浏览器直连的 CORS/证书/网络限制)
const probeResponse = await cloudSyncApi.probeServerUrl(serverUrlSaved.value);
probeResult.value = {
type: probeResponse.type as ProbeType,
title: probeResponse.title,
desc: probeResponse.description,
};
} catch (err) {
probeResult.value = {
type: 'error',
title: '探测失败',
desc: '无法连接到服务端,请检查地址是否正确',
};
} finally { } finally {
isProbing.value = false; isProbing.value = false;
} }
@@ -337,6 +443,12 @@ const saveServerUrl = async () => {
emitCloudSyncStateChanged(); emitCloudSyncStateChanged();
}; };
/**
* 执行登录
* 调用 cloudSyncApi.login 完成登录,登录成功后刷新安全策略
*
* @fires cloudSyncStateChanged - 登录成功后触发
*/
const login = async () => { const login = async () => {
if (!canLogin.value) return; if (!canLogin.value) return;
isLoggingIn.value = true; isLoggingIn.value = true;
@@ -346,70 +458,76 @@ const login = async () => {
password: password.value, password: password.value,
}); });
showToast('登录成功', 'success'); showToast('登录成功', 'success');
refreshSession();
await refreshPolicy(); await refreshPolicy();
emitCloudSyncStateChanged(); emitCloudSyncStateChanged();
await pullTasks();
} finally { } finally {
isLoggingIn.value = false; isLoggingIn.value = false;
} }
}; };
const logout = () => { /**
cloudSyncApi.logout(); * 执行登出
* 清除本地会话和安全策略,重置落盘策略为允许
*
* @fires cloudSyncStateChanged - 登出后触发
*/
const logout = async () => {
await cloudSyncApi.logout();
refreshSession();
policy.value = null; policy.value = null;
cloudSyncState.policy = { allowPersist: true, allowSync: true, requireSecondFactorFor: [] }; cloudSyncState.policy = { allowPersist: true, allowSync: true };
LocalStorageService.setAllowPersist(true); // 登出后重置 LocalStorageService.setAllowPersist(true); // 登出后重置
showToast('已登出', 'success'); showToast('已登出', 'success');
emitCloudSyncStateChanged(); emitCloudSyncStateChanged();
}; };
const confirmStepUp = async () => { /**
if (!stepUpPassword.value || isSteppingUp.value) return; * 触发同步事件
isSteppingUp.value = true; * 通知父组件执行同步操作(推送本地任务到云端并获取最新数据)
stepUpError.value = ''; *
* @fires cloudSyncTasksRequested
*/
const syncNow = async () => {
if (!canSync.value || isSyncing.value) return;
isSyncing.value = true;
try { try {
await cloudSyncApi.stepUp(stepUpPassword.value); refreshSyncStatus(); // 先更新状态
showToast('二次认证成功', 'success'); const event = new CustomEvent('cloudSyncTasksRequested');
cloudSyncState.isSteppingUp = false; window.dispatchEvent(event);
stepUpPassword.value = ''; // 延迟刷新状态,等待同步完成
// 如果有挂起的回调,执行它 setTimeout(() => refreshSyncStatus(), 500);
if (cloudSyncState.onStepUpSuccess) {
cloudSyncState.onStepUpSuccess();
cloudSyncState.onStepUpSuccess = null;
}
} catch (err: any) {
stepUpError.value = '认证失败,请检查密码';
} finally { } finally {
isSteppingUp.value = false; // 保持 isSyncing 状态一段时间,让用户看到反馈
setTimeout(() => {
isSyncing.value = false;
}, 1500);
} }
}; };
const cancelStepUp = () => { /**
cloudSyncState.isSteppingUp = false; * 打开设置对话框
cloudSyncState.onStepUpSuccess = null; * 加载本地状态并重置探测结果
stepUpPassword.value = ''; */
stepUpError.value = '';
};
const pullTasks = async () => {
const event = new CustomEvent('cloudSyncPullTasksRequested');
window.dispatchEvent(event);
};
function handleOpenSettings() { function handleOpenSettings() {
loadLocalState(); loadLocalState();
probeResult.value = null; probeResult.value = null;
isOpen.value = true; isOpen.value = true;
} }
/** 关闭对话框 */
function close() { function close() {
isOpen.value = false; isOpen.value = false;
} }
// ========== 生命周期 ==========
onMounted(() => { onMounted(() => {
// 监听打开设置对话框事件
window.addEventListener('openCloudSyncSettings', handleOpenSettings); window.addEventListener('openCloudSyncSettings', handleOpenSettings);
// 初始化:如果已登录,自动获取策略 // 初始化:加载同步状态,如果已登录获取策略
refreshSyncStatus();
if (isLoggedIn.value) { if (isLoggedIn.value) {
refreshPolicy(); refreshPolicy();
} }
@@ -557,6 +675,41 @@ onMounted(() => {
font-weight: 550; font-weight: 550;
} }
/* 同步状态区域 */
.sync-status {
margin-top: 10px;
padding: 10px;
background: #f0f9ff;
border-radius: 8px;
border: 1px solid #e0f2fe;
display: flex;
flex-direction: column;
gap: 6px;
}
.sync-status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.sync-status-item .label {
color: #64748b;
width: 70px;
flex-shrink: 0;
}
.sync-status .success {
color: #059669;
font-weight: 550;
}
.sync-status .offline {
color: #94a3b8;
font-weight: 550;
}
.form-row { .form-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -633,15 +786,6 @@ onMounted(() => {
background: #f9fafb; background: #f9fafb;
} }
.step-up-overlay {
z-index: 1100;
background: rgba(0, 0, 0, 0.6);
}
.step-up-dialog {
max-width: 400px;
}
.error-text { .error-text {
color: #ef4444; color: #ef4444;
font-size: 13px; font-size: 13px;
@@ -682,4 +826,16 @@ onMounted(() => {
background: #f3f4f6; background: #f3f4f6;
border-color: #9ca3af; border-color: #9ca3af;
} }
.btn-outline {
background: white;
color: #3b82f6;
border: 1px solid #3b82f6;
}
.btn-outline:hover:enabled {
background: #eff6ff;
border-color: #2563eb;
color: #2563eb;
}
</style> </style>
+3 -35
View File
@@ -4,14 +4,13 @@
<input <input
type="checkbox" type="checkbox"
:checked="task.isCompleted" :checked="task.isCompleted"
:disabled="readOnly"
@change="toggleComplete" @change="toggleComplete"
class="task-checkbox" class="task-checkbox"
/> />
<button v-if="task.subTasks && task.subTasks.length > 0" class="task-expand" @click.stop="toggleExpand" :title="isExpanded ? '收起' : '展开'"> <button v-if="task.subTasks && task.subTasks.length > 0" class="task-expand" @click.stop="toggleExpand" :title="isExpanded ? '收起' : '展开'">
<span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span> <span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span>
</button> </button>
<button v-if="!readOnly" @click="startAddSubTask" class="btn-add-subtask-icon" title="添加子任务"> <button @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"> <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" /> <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> </svg>
@@ -25,13 +24,13 @@
</div> </div>
</div> </div>
<div class="task-actions"> <div class="task-actions">
<button v-if="!readOnly" @click="openEditDialog" class="btn-edit-icon" title="编辑"> <button @click="openEditDialog" class="btn-edit-icon" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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="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" /> <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> </svg>
</button> </button>
<button v-if="!readOnly" @click="deleteTask" class="btn-delete-icon" title="删除"> <button @click="deleteTask" class="btn-delete-icon" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@@ -71,7 +70,6 @@
v-for="subTask in task.subTasks" v-for="subTask in task.subTasks"
:key="subTask.id" :key="subTask.id"
:task="subTask" :task="subTask"
:readOnly="readOnly"
@updated="handleSubTaskUpdated" @updated="handleSubTaskUpdated"
@deleted="handleSubTaskDeleted" @deleted="handleSubTaskDeleted"
@subtask-created="$emit('subtask-created', $event)" @subtask-created="$emit('subtask-created', $event)"
@@ -94,11 +92,9 @@ import type { Task } from '../types/task';
interface Props { interface Props {
task: Task; task: Task;
readOnly?: boolean;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const readOnly = computed(() => props.readOnly === true);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'updated', task: Task): void; (e: 'updated', task: Task): void;
@@ -114,10 +110,6 @@ const newSubTaskPriority = ref<0 | 1 | 2>(1);
const subTaskInput = ref<HTMLInputElement | null>(null); const subTaskInput = ref<HTMLInputElement | null>(null);
const startAddSubTask = async () => { const startAddSubTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
showAddSubTask.value = true; showAddSubTask.value = true;
await nextTick(); await nextTick();
if (subTaskInput.value) { if (subTaskInput.value) {
@@ -160,10 +152,6 @@ const toggleExpand = () => {
}; };
const toggleComplete = async () => { const toggleComplete = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
try { try {
const response = await taskApi.toggleComplete(props.task.id); const response = await taskApi.toggleComplete(props.task.id);
if (response.success && response.data) { if (response.success && response.data) {
@@ -175,10 +163,6 @@ const toggleComplete = async () => {
}; };
const deleteTask = async () => { const deleteTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
try { try {
const response = await taskApi.deleteTask(props.task.id); const response = await taskApi.deleteTask(props.task.id);
if (response.success) { if (response.success) {
@@ -190,10 +174,6 @@ const deleteTask = async () => {
}; };
const createSubTask = async () => { const createSubTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
if (!newSubTaskTitle.value.trim()) { if (!newSubTaskTitle.value.trim()) {
alert('请输入子任务标题'); alert('请输入子任务标题');
return; return;
@@ -235,24 +215,12 @@ const handleSubTaskDeleted = (subTaskId: number) => {
}; };
const openEditDialog = () => { const openEditDialog = () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
showEditDialog.value = true; showEditDialog.value = true;
}; };
const handleTaskSaved = (updatedTask: Task) => { const handleTaskSaved = (updatedTask: Task) => {
emit('updated', updatedTask); emit('updated', updatedTask);
}; };
const notifyReadOnly = () => {
if ((window as any).showToast) {
(window as any).showToast('云同步模式为只读展示,编辑/新增将在后续版本补齐', 'error');
} else {
window.alert('云同步模式为只读展示,编辑/新增将在后续版本补齐');
}
};
</script> </script>
<style scoped> <style scoped>
+83 -10
View File
@@ -74,6 +74,18 @@
</svg> </svg>
<span>云同步</span> <span>云同步</span>
</button> </button>
<button
v-if="isCloudSyncEnabled && isCloudLoggedIn"
class="cloud-sync-btn sync-action-btn"
:disabled="isSyncing"
@click="syncNow"
title="立即同步:将本地变更推送到云端并拉取最新数据"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 0 0 4.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 0 1-15.357-2m15.357 2H15" />
</svg>
<span>{{ isSyncing ? '同步中...' : '同步' }}</span>
</button>
</div> </div>
</div> </div>
<div class="task-info"> <div class="task-info">
@@ -86,7 +98,7 @@
</span> </span>
</div> </div>
</div> </div>
<div v-if="!isReadOnly" class="task-form"> <div class="task-form">
<input <input
v-model="newTaskTitle" v-model="newTaskTitle"
type="text" type="text"
@@ -106,9 +118,6 @@
</svg> </svg>
</button> </button>
</div> </div>
<div v-else class="cloud-readonly-banner">
云同步模式只读展示v1.2.0编辑/新增将在后续版本补齐
</div>
<div v-if="filteredTasks.length === 0" class="empty"> <div v-if="filteredTasks.length === 0" class="empty">
{{ searchQuery.trim().length > 0 ? '无匹配任务' : '暂无任务' }} {{ searchQuery.trim().length > 0 ? '无匹配任务' : '暂无任务' }}
@@ -120,7 +129,6 @@
v-for="task in filteredTasks" v-for="task in filteredTasks"
:key="task.id" :key="task.id"
:task="task" :task="task"
:readOnly="isReadOnly"
@updated="handleTaskUpdated" @updated="handleTaskUpdated"
@deleted="handleTaskDeleted" @deleted="handleTaskDeleted"
@subtask-created="handleSubTaskCreated" @subtask-created="handleSubTaskCreated"
@@ -155,6 +163,15 @@ const searchQuery = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null); const searchInputRef = ref<HTMLInputElement | null>(null);
const isCloudSyncEnabled = ref(false); const isCloudSyncEnabled = ref(false);
const isCloudLoggedIn = ref(false); const isCloudLoggedIn = ref(false);
const isSyncing = ref(false);
const showToast = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
const toggleSortOrder = () => { const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'; sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
@@ -245,7 +262,7 @@ const filteredTasks = computed(() => {
return sortTasks(searchFilteredRootTasks); return sortTasks(searchFilteredRootTasks);
}); });
const isReadOnly = computed(() => isCloudSyncEnabled.value && isCloudLoggedIn.value); const isReadOnly = computed(() => false);
const cloudStatusLabel = computed(() => { const cloudStatusLabel = computed(() => {
if (!isCloudSyncEnabled.value) return ''; if (!isCloudSyncEnabled.value) return '';
@@ -369,6 +386,7 @@ const createTask = async () => {
tasks.value.unshift(response.data); tasks.value.unshift(response.data);
newTaskTitle.value = ''; newTaskTitle.value = '';
newTaskPriority.value = 1; newTaskPriority.value = 1;
lastSyncTime.value = Date.now();
} }
} catch (error) { } catch (error) {
console.error('Failed to create task:', error); console.error('Failed to create task:', error);
@@ -378,6 +396,7 @@ const createTask = async () => {
const handleTaskUpdated = (updatedTask: Task) => { const handleTaskUpdated = (updatedTask: Task) => {
updateTaskInTree(tasks.value, updatedTask); updateTaskInTree(tasks.value, updatedTask);
lastSyncTime.value = Date.now();
}; };
const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => { const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
@@ -397,6 +416,7 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
const handleTaskDeleted = (taskId: number) => { const handleTaskDeleted = (taskId: number) => {
deleteTaskFromTree(tasks.value, taskId); deleteTaskFromTree(tasks.value, taskId);
lastSyncTime.value = Date.now();
}; };
const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => { const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => {
@@ -416,6 +436,7 @@ const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => {
const handleSubTaskCreated = (subTask: Task) => { const handleSubTaskCreated = (subTask: Task) => {
addSubTaskToTree(tasks.value, subTask); addSubTaskToTree(tasks.value, subTask);
lastSyncTime.value = Date.now();
}; };
const addSubTaskToTree = (tasks: Task[], subTask: Task): boolean => { const addSubTaskToTree = (tasks: Task[], subTask: Task): boolean => {
@@ -463,9 +484,44 @@ const handleCloudSyncStateChanged = () => {
loadTasks(); loadTasks();
}; };
const handleCloudSyncPullTasksRequested = () => { /**
* 立即同步:将本地任务推送到云端并获取云端最新数据。
* 通过 cloudSyncApi.syncTasks 调用 POST /sync/:推送 upserts 列表,云端返回最终状态。
*/
const syncNow = async () => {
if (isSyncing.value) return;
isSyncing.value = true;
try {
const localTasks = tasks.value.length > 0
? tasks.value
: LocalStorageService.loadTasks();
const { tasks: cloudTasks, serverTimeUtc } = await cloudSyncApi.syncTasks(localTasks);
tasks.value = cloudTasks;
LocalStorageService.saveTasks(cloudTasks);
const syncStatus = LocalStorageService.loadSyncStatus();
const serverTimestamp = serverTimeUtc ? new Date(serverTimeUtc).getTime() : Date.now();
syncStatus.lastSyncTime = serverTimestamp;
syncStatus.isOnline = true;
syncStatus.pendingChanges = 0;
LocalStorageService.saveSyncStatus(syncStatus);
lastSyncTime.value = syncStatus.lastSyncTime;
pendingChanges.value = 0;
LocalStorageService.syncTaskIdCounter(cloudTasks);
showToast('同步成功', 'success');
} catch (error) {
console.error('Sync failed:', error);
showToast('同步失败,请检查网络或登录状态');
} finally {
isSyncing.value = false;
}
};
/**
* 处理对话框触发的同步请求。
*/
const handleCloudSyncTasksRequested = () => {
refreshCloudModeState(); refreshCloudModeState();
loadTasks(); syncNow();
}; };
onMounted(() => { onMounted(() => {
@@ -475,14 +531,14 @@ onMounted(() => {
window.addEventListener('online', handleOnlineStatus); window.addEventListener('online', handleOnlineStatus);
window.addEventListener('offline', handleOnlineStatus); window.addEventListener('offline', handleOnlineStatus);
window.addEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener); window.addEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.addEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener); window.addEventListener('cloudSyncTasksRequested', handleCloudSyncTasksRequested as EventListener);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('online', handleOnlineStatus); window.removeEventListener('online', handleOnlineStatus);
window.removeEventListener('offline', handleOnlineStatus); window.removeEventListener('offline', handleOnlineStatus);
window.removeEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener); window.removeEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.removeEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener); window.removeEventListener('cloudSyncTasksRequested', handleCloudSyncTasksRequested as EventListener);
}); });
</script> </script>
@@ -897,6 +953,23 @@ onUnmounted(() => {
font-size: inherit; font-size: inherit;
} }
.sync-action-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
box-shadow: 0 2px 8px -2px rgba(16, 185, 129, 0.35);
}
.sync-action-btn:hover {
box-shadow: 0 4px 12px -2px rgba(16, 185, 129, 0.45);
}
.sync-action-btn:disabled {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
box-shadow: none;
cursor: not-allowed;
opacity: 0.7;
transform: none;
}
.cloud-readonly-banner { .cloud-readonly-banner {
margin-bottom: 8px; margin-bottom: 8px;
padding: 10px 12px; padding: 10px 12px;
@@ -17,18 +17,7 @@ export const cloudSyncState = reactive({
policy: { policy: {
allowPersist: true, allowPersist: true,
allowSync: true, allowSync: true,
requireSecondFactorFor: [] as string[],
}, },
/**
* 是否正在进行二次认证流程。
*/
isSteppingUp: false,
/**
* 二次认证成功后的回调(用于重试之前失败的操作)。
*/
onStepUpSuccess: null as (() => void) | null,
}); });
export default cloudSyncState; export default cloudSyncState;
@@ -3,6 +3,7 @@ import { normalizeTasks } from './taskNormalizer';
const STORAGE_KEY = 'Hua.Todo_tasks'; const STORAGE_KEY = 'Hua.Todo_tasks';
const SYNC_STATUS_KEY = 'Hua.Todo_sync_status'; const SYNC_STATUS_KEY = 'Hua.Todo_sync_status';
const NEXT_TASK_ID_KEY = 'Hua.Todo_next_task_id';
/** /**
* 内存存储备份(当禁止落盘时使用)。 * 内存存储备份(当禁止落盘时使用)。
@@ -10,6 +11,7 @@ const SYNC_STATUS_KEY = 'Hua.Todo_sync_status';
const memoryStore = { const memoryStore = {
tasks: [] as Task[], tasks: [] as Task[],
syncStatus: null as SyncStatus | null, syncStatus: null as SyncStatus | null,
nextTaskId: 1,
}; };
/** /**
@@ -34,7 +36,18 @@ export class LocalStorageService {
if (previous && !allowed) { if (previous && !allowed) {
this.clearTasks(); this.clearTasks();
localStorage.removeItem(SYNC_STATUS_KEY); localStorage.removeItem(SYNC_STATUS_KEY);
localStorage.removeItem(NEXT_TASK_ID_KEY);
memoryStore.nextTaskId = 1;
console.log('CloudSync: Security policy changed to "No Persist", local data cleared.'); console.log('CloudSync: Security policy changed to "No Persist", local data cleared.');
} else if (!previous && allowed) {
// 从禁止切回允许时,将内存中累计的 nextTaskId 写回 localStorage
if (memoryStore.nextTaskId > 1) {
try {
localStorage.setItem(NEXT_TASK_ID_KEY, String(memoryStore.nextTaskId));
} catch {
// 忽略落盘错误
}
}
} }
} }
@@ -59,7 +72,10 @@ export class LocalStorageService {
try { try {
const data = localStorage.getItem(STORAGE_KEY); const data = localStorage.getItem(STORAGE_KEY);
return data ? normalizeTasks(JSON.parse(data)) : []; const tasks = data ? normalizeTasks(JSON.parse(data)) : [];
// 每次读取时同步一次 nextTaskId 计数器,保证后续生成的新 ID 不会与现有冲突
this.syncTaskIdCounter(tasks);
return tasks;
} catch (error) { } catch (error) {
console.error('Failed to load tasks from localStorage:', error); console.error('Failed to load tasks from localStorage:', error);
return []; return [];
@@ -113,6 +129,79 @@ export class LocalStorageService {
static isOnline(): boolean { static isOnline(): boolean {
return navigator.onLine; return navigator.onLine;
} }
/**
* 获取下一个可用的任务 ID(自增,保证全局唯一且递增)。
* 读取当前值后立即自增并持久化。
* @returns 分配给新任务的 ID
*/
static getNextTaskId(): number {
let current: number;
if (!allowPersist) {
current = memoryStore.nextTaskId;
memoryStore.nextTaskId = current + 1;
return current;
}
try {
const raw = localStorage.getItem(NEXT_TASK_ID_KEY);
current = raw ? Number(raw) : 0;
if (!Number.isFinite(current) || current < 1) current = 1;
} catch {
current = 1;
}
const next = current + 1;
try {
localStorage.setItem(NEXT_TASK_ID_KEY, String(next));
} catch {
// 忽略落盘错误,下次将从 1 开始;运行时至少保证返回值递增
}
return current;
}
/**
* 根据现有任务集合同步 nextTaskId 计数器,确保它大于所有已存在的任务 ID。
* 用于从后端加载任务后或初始化本地数据后防止 ID 冲突。
* @param tasks 当前已知的任务列表(可以包含子任务,会递归遍历)
*/
static syncTaskIdCounter(tasks: Task[]): void {
if (!tasks || tasks.length === 0) return;
let maxId = 0;
const walk = (list: Task[]): void => {
for (const t of list) {
if (typeof t.id === 'number' && Number.isFinite(t.id) && t.id > maxId) {
maxId = t.id;
}
if (t.subTasks && t.subTasks.length > 0) {
walk(t.subTasks);
}
}
};
walk(tasks);
if (maxId < 1) return;
const target = maxId + 1;
if (!allowPersist) {
if (memoryStore.nextTaskId <= maxId) {
memoryStore.nextTaskId = target;
}
return;
}
try {
const raw = localStorage.getItem(NEXT_TASK_ID_KEY);
const current = raw ? Number(raw) : 0;
if (!Number.isFinite(current) || current < target) {
localStorage.setItem(NEXT_TASK_ID_KEY, String(target));
}
} catch {
// 忽略落盘错误
}
}
} }
export default LocalStorageService; export default LocalStorageService;
+54 -7
View File
@@ -1,6 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { resolve } from 'path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
// Use `vite build --mode maui` to emit artifacts into the MAUI app's `wwwroot` for embedded hosting. // Use `vite build --mode maui` to emit artifacts into the MAUI app's `wwwroot` for embedded hosting.
@@ -8,6 +11,20 @@ export default defineConfig(({ mode }) => {
? resolve(__dirname, '../Hua.Todo.Maui/wwwroot') ? resolve(__dirname, '../Hua.Todo.Maui/wwwroot')
: resolve(__dirname, 'dist') : resolve(__dirname, 'dist')
// 主动加载 .env 文件,vite.config.ts 执行时 Vite 尚未自动加载
const env = loadEnv(mode, __dirname, '')
// Dev 模式下各端点的代理目标,严格从环境变量读取:
// - VITE_API_TARGET(必需): API 及云同步共用的代理目标
// - VITE_CLOUD_SYNC_URL(可选): 云同步独立远端地址,未设置时回退到 VITE_API_TARGET
// Build 模式下 proxy 配置不生效(无 dev server),仅 dev 模式使用
const apiTarget = env.VITE_API_TARGET
const cloudSyncTarget = env.VITE_CLOUD_SYNC_URL || apiTarget
if (!apiTarget) {
throw new Error(`[vite.config] 缺少必需的环境变量: VITE_API_TARGET。请检查 .env.${mode} 文件。`)
}
return { return {
plugins: [vue()], plugins: [vue()],
build: { build: {
@@ -15,16 +32,46 @@ export default defineConfig(({ mode }) => {
emptyOutDir: true, emptyOutDir: true,
}, },
define: { define: {
'window.__API_BASE_URL__': JSON.stringify('/api') // 非 MAUI 模式下静态替换 API 基地址;MAUI 模式下由宿主在运行时注入
...(mode !== 'maui' ? { 'window.__API_BASE_URL__': JSON.stringify('/api') } : {}),
// MAUI 模式标识:用于区分云同步请求的行为
'window.__IS_MAUI__': JSON.stringify(mode === 'maui'),
}, },
server: { server: {
port: 5174, port: 5174,
proxy: { proxy: {
// 本地 API 代理到后端
'/api': { '/api': {
target: 'http://localhost:5173', target: apiTarget,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
// 云同步端点代理到远端服务器
'/auth': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/tasks': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/sync': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/security': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/cloud-sync': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
}, },
}, },
} }