feat:云同步,部分功能生效
This commit is contained in:
@@ -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
|
||||
@@ -1,30 +1,27 @@
|
||||
# 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。
|
||||
- **任务管理**:支持创建、编辑、删除、完成状态切换、子任务管理。
|
||||
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略(SecurityPolicy)配置。
|
||||
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感。
|
||||
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析。
|
||||
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试。
|
||||
- **跨平台支持**:基于 MAUI + Avalonia + WebView 架构,覆盖 Windows、macOS、Android、iOS 和 Linux 五大平台
|
||||
- **任务管理**:支持任务创建、编辑、删除、完成状态切换,以及子任务管理
|
||||
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略配置
|
||||
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感
|
||||
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析
|
||||
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试
|
||||
- **全局快捷键**:支持系统级快捷键快速唤起应用
|
||||
|
||||
## 📦 安装与使用
|
||||
|
||||
### 环境要求
|
||||
- **后端**:
|
||||
- .NET 10 SDK
|
||||
- Visual Studio 2022 或更高版本
|
||||
- **前端**:
|
||||
- Node.js 18+
|
||||
- npm 或 yarn
|
||||
- **后端**:.NET 10 SDK + Visual Studio 2022 或更高版本
|
||||
- **前端**:Node.js 18+ + npm 或 yarn
|
||||
|
||||
### 快速开始
|
||||
|
||||
#### 1. 克隆或下载项目
|
||||
#### 1. 克隆项目
|
||||
```bash
|
||||
git clone <仓库地址>
|
||||
cd Hua.Todo
|
||||
@@ -37,7 +34,7 @@ dotnet restore
|
||||
dotnet run
|
||||
```
|
||||
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
|
||||
```bash
|
||||
@@ -45,31 +42,38 @@ cd src/Hua.Todo.Web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
|
||||
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到后端
|
||||
|
||||
#### 4. 启动 MAUI 客户端(Windows 三件套开发)
|
||||
- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)` 与 `Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。
|
||||
- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI):
|
||||
|
||||
推荐使用脚本一键启动:
|
||||
```powershell
|
||||
.\start-dev.ps1
|
||||
```
|
||||
|
||||
### Windows 交付产物(安装包)
|
||||
### 构建与发布
|
||||
|
||||
- 运行 `publish-windows.ps1` 默认使用 `Release` 配置生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj` 的 `<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`)
|
||||
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows:`publish.ps1 -Windows`),且均默认使用 `Release` 配置。
|
||||
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件)
|
||||
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json` 中 `WebServer.IsUsingStatic=true`
|
||||
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件)
|
||||
#### Windows 交付产物
|
||||
```powershell
|
||||
# 生成 Windows 安装包(Inno Setup)
|
||||
.\publish-windows.ps1
|
||||
```
|
||||
输出:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`
|
||||
|
||||
### Linux 交付产物(v1.2.0)
|
||||
|
||||
- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux`)
|
||||
- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/`
|
||||
#### Linux 交付产物
|
||||
```powershell
|
||||
# 生成 Linux 发布包
|
||||
.\publish-linux.ps1
|
||||
```
|
||||
或使用统一发布脚本:
|
||||
```powershell
|
||||
# 默认发布 Windows + Linux
|
||||
.\publish.ps1
|
||||
# 仅发布 Windows
|
||||
.\publish.ps1 -Windows
|
||||
```
|
||||
|
||||
### 使用说明
|
||||
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
|
||||
- **添加任务**:在前端界面输入任务内容,设置优先级,点击添加按钮
|
||||
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
|
||||
- **完成任务**:点击任务前的复选框切换完成状态
|
||||
- **删除任务**:点击删除按钮移除任务
|
||||
@@ -86,31 +90,70 @@ Hua.Todo/
|
||||
├── src/ # 源代码目录
|
||||
│ ├── Hua.Todo.Core/ # 领域实体与基础接口
|
||||
│ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现
|
||||
│ │ ├── CloudSync/ # 云同步模块
|
||||
│ │ ├── Data/ # 数据访问层
|
||||
│ │ └── DynamicApi/ # 动态 API 生成
|
||||
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
|
||||
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
|
||||
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
|
||||
│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux)
|
||||
│ └── Hua.Todo.slnx # 解决方案文件
|
||||
│ ├── Hua.Todo.Maui/ # 跨平台客户端 (Windows/Android/iOS/macOS)
|
||||
│ └── Hua.Todo.Avalonia/ # 桌面客户端 (Windows/macOS/Linux)
|
||||
├── .gitignore # Git 忽略文件
|
||||
└── 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 端点
|
||||
- `GET /api/task` - 获取任务列表(默认:全部)
|
||||
- `GET /api/task/active` - 获取未完成任务
|
||||
- `GET /api/task/completed` - 获取已完成任务
|
||||
- `GET /api/task/{id}` - 获取单个任务
|
||||
- `POST /api/task` - 创建任务
|
||||
- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位)
|
||||
- `PATCH /api/task/{id}/toggle` - 切换完成状态
|
||||
- `DELETE /api/task/{id}` - 删除任务
|
||||
- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表
|
||||
|
||||
#### 任务管理
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/task` | 获取任务列表(默认:全部) |
|
||||
| GET | `/api/task/active` | 获取未完成任务 |
|
||||
| GET | `/api/task/completed` | 获取已完成任务 |
|
||||
| GET | `/api/task/{id}` | 获取单个任务 |
|
||||
| POST | `/api/task` | 创建任务 |
|
||||
| PUT | `/api/task` | 更新任务(通过 Body 内的 id 定位) |
|
||||
| PATCH | `/api/task/{id}/toggle` | 切换完成状态 |
|
||||
| DELETE | `/api/task/{id}` | 删除任务 |
|
||||
| GET | `/api/task/{parentTaskId}/subtasks` | 获取子任务列表 |
|
||||
|
||||
#### 云同步(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 交流群)
|
||||
- **项目地址**:[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/project/产品需求文档.md)
|
||||
- [Android 离线排查计划](docs/project/Android_NotFound_排查计划.md)
|
||||
- [v1.2.0 任务清单](docs/project/v1.2.0-tasks/00-任务总览.md)
|
||||
- [实现对比文档](docs/project/实现对比文档.md)
|
||||
|
||||
---
|
||||
|
||||
**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 (新增) │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
**模式 B:MAUI 嵌入式 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` 写入 DB(session 管理在 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 | **新增** | Host(Vite 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 无法访问云同步端点 |
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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));
|
||||
}
|
||||
|
||||
if (session.StepUpExpiresAtUtc.HasValue && session.StepUpExpiresAtUtc.Value > now)
|
||||
{
|
||||
claims.Add(new Claim(CloudClaims.StepUp, "true"));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
|
||||
@@ -21,7 +21,7 @@ public static class CloudSyncEndpointExtensions
|
||||
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
|
||||
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
|
||||
auth.MapPost("/login", LoginAsync).AllowAnonymous();
|
||||
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization();
|
||||
auth.MapPost("/logout", LogoutAsync).RequireAuthorization();
|
||||
auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization();
|
||||
|
||||
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
|
||||
@@ -43,6 +43,10 @@ public static class CloudSyncEndpointExtensions
|
||||
admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync);
|
||||
admin.MapGet("/audit-logs", GetAuditLogsAsync);
|
||||
|
||||
// 服务端探测端点(允许匿名访问,用于探测用户配置的外部服务端)
|
||||
var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup");
|
||||
probe.MapPost("/probe", ProbeAsync).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -95,33 +99,6 @@ public static class CloudSyncEndpointExtensions
|
||||
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(
|
||||
ChangePasswordRequest request,
|
||||
CloudAuthService authService,
|
||||
@@ -154,6 +131,21 @@ public static class CloudSyncEndpointExtensions
|
||||
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(
|
||||
CloudTaskSyncService taskService,
|
||||
HttpContext httpContext,
|
||||
@@ -191,11 +183,6 @@ public static class CloudSyncEndpointExtensions
|
||||
return CloudApiErrors.Forbidden();
|
||||
}
|
||||
|
||||
if (!httpContext.User.HasStepUp())
|
||||
{
|
||||
return CloudApiErrors.SecondFactorRequired();
|
||||
}
|
||||
|
||||
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
|
||||
return Results.Json(response);
|
||||
}
|
||||
@@ -237,12 +224,7 @@ public static class CloudSyncEndpointExtensions
|
||||
return CloudApiErrors.Forbidden();
|
||||
}
|
||||
|
||||
if (!httpContext.User.HasStepUp())
|
||||
{
|
||||
return CloudApiErrors.SecondFactorRequired();
|
||||
}
|
||||
|
||||
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.SecondFactorExpiryMinutes, request.IsTrustedDeviceOnly, cancellationToken);
|
||||
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, cancellationToken);
|
||||
return Results.Json(policy);
|
||||
}
|
||||
|
||||
@@ -345,5 +327,19 @@ public static class CloudSyncEndpointExtensions
|
||||
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
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ public static class CloudSyncServiceCollectionExtensions
|
||||
services.AddScoped<CloudTaskSyncService>();
|
||||
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)
|
||||
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
|
||||
|
||||
|
||||
@@ -56,5 +56,4 @@ public class SessionDto
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public DateTime CreatedAtUtc { 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;
|
||||
}
|
||||
|
||||
/// <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.")
|
||||
=> 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>
|
||||
|
||||
@@ -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>
|
||||
public bool AllowSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 需要二次认证的操作列表(操作码)。
|
||||
/// </summary>
|
||||
public List<string> RequireSecondFactorFor { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 二次认证有效期(分钟)。
|
||||
/// </summary>
|
||||
public int SecondFactorExpiryMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅限受信任设备。
|
||||
/// </summary>
|
||||
@@ -46,11 +36,6 @@ public class UpdateSecurityPolicyRequest
|
||||
/// </summary>
|
||||
public bool AllowSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 二次认证有效期(分钟)。
|
||||
/// </summary>
|
||||
public int SecondFactorExpiryMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅限受信任设备。
|
||||
/// </summary>
|
||||
|
||||
@@ -58,7 +58,7 @@ public class CloudAdminService
|
||||
_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);
|
||||
|
||||
@@ -103,8 +103,7 @@ public class CloudAdminService
|
||||
UserId = s.UserId,
|
||||
UserName = s.User != null ? s.User.UserName : "Unknown",
|
||||
CreatedAtUtc = s.CreatedAtUtc,
|
||||
ExpiresAtUtc = s.ExpiresAtUtc,
|
||||
IsSteppedUp = s.StepUpExpiresAtUtc.HasValue && s.StepUpExpiresAtUtc.Value > now
|
||||
ExpiresAtUtc = s.ExpiresAtUtc
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ public class CloudAuthService
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, generatedPassword);
|
||||
|
||||
_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 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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -217,55 +217,6 @@ public class CloudAuthService
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -317,5 +268,21 @@ public class CloudAuthService
|
||||
await LogAsync("ChangePasswordSuccess", "Password changed successfully.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||
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)
|
||||
{
|
||||
foreach (var upsert in request.Upserts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upsert.Title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 去重:同一 Id 保留最后一条(后发送覆盖先发送)
|
||||
var uniqueUpserts = request.Upserts
|
||||
.GroupBy(u => u.Id)
|
||||
.Select(g => g.Last())
|
||||
.Where(u => !string.IsNullOrWhiteSpace(u.Title))
|
||||
.ToList();
|
||||
|
||||
if (upsert.Id.HasValue)
|
||||
// 预先查询该用户所有已存在的任务 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))
|
||||
{
|
||||
var existing = await _dbContext.Tasks
|
||||
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 (existing != null)
|
||||
if (entity != null)
|
||||
{
|
||||
existing.Title = upsert.Title.Trim();
|
||||
existing.Priority = upsert.Priority;
|
||||
existing.IsCompleted = upsert.IsCompleted;
|
||||
existing.ParentTaskId = upsert.ParentTaskId;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
continue;
|
||||
entity.Title = upsert.Title.Trim();
|
||||
entity.Priority = upsert.Priority;
|
||||
entity.IsCompleted = upsert.IsCompleted;
|
||||
entity.ParentTaskId = null;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
var entity = new TaskEntity
|
||||
if (entity == null)
|
||||
{
|
||||
// 新建任务;EF Core 会自动分配新的服务器 ID
|
||||
entity = new TaskEntity
|
||||
{
|
||||
UserId = userId,
|
||||
Title = upsert.Title.Trim(),
|
||||
Priority = upsert.Priority,
|
||||
IsCompleted = upsert.IsCompleted,
|
||||
ParentTaskId = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ParentTaskId = upsert.ParentTaskId
|
||||
};
|
||||
|
||||
_dbContext.Tasks.Add(entity);
|
||||
|
||||
if (upsert.Id.HasValue)
|
||||
{
|
||||
clientIdToEntity[upsert.Id.Value] = entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存根任务,获取服务器分配的 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
|
||||
{
|
||||
|
||||
@@ -37,8 +37,6 @@ public class SecurityPolicyService
|
||||
{
|
||||
AllowPersist = policy?.AllowPersist ?? true,
|
||||
AllowSync = policy?.AllowSync ?? true,
|
||||
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
|
||||
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
|
||||
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
|
||||
};
|
||||
}
|
||||
@@ -49,11 +47,10 @@ public class SecurityPolicyService
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="allowPersist">是否允许落盘。</param>
|
||||
/// <param name="allowSync">是否允许同步。</param>
|
||||
/// <param name="secondFactorExpiryMinutes">二次认证有效期。</param>
|
||||
/// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>更新后的策略 DTO。</returns>
|
||||
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, int secondFactorExpiryMinutes, bool isTrustedDeviceOnly, CancellationToken cancellationToken)
|
||||
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);
|
||||
if (policy == null)
|
||||
@@ -64,7 +61,6 @@ public class SecurityPolicyService
|
||||
UserId = userId,
|
||||
AllowPersist = allowPersist,
|
||||
AllowSync = allowSync,
|
||||
SecondFactorExpiryMinutes = secondFactorExpiryMinutes,
|
||||
IsTrustedDeviceOnly = isTrustedDeviceOnly
|
||||
};
|
||||
_dbContext.SecurityPolicies.Add(policy);
|
||||
@@ -73,7 +69,6 @@ public class SecurityPolicyService
|
||||
{
|
||||
policy.AllowPersist = allowPersist;
|
||||
policy.AllowSync = allowSync;
|
||||
policy.SecondFactorExpiryMinutes = secondFactorExpiryMinutes;
|
||||
policy.IsTrustedDeviceOnly = isTrustedDeviceOnly;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,15 @@ public class DynamicApiMiddleware
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
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")
|
||||
{
|
||||
await _next(context);
|
||||
|
||||
@@ -104,11 +104,6 @@
|
||||
</template>
|
||||
<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="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">
|
||||
<template #default="scope">{{ formatDate(scope.row.expiresAtUtc) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"WebServer": {
|
||||
"Port": 5057,
|
||||
"IsUsingStatic": true,
|
||||
"IsUsingStatic": false,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5057"
|
||||
"ForEndUrl": "http://localhost:5174"
|
||||
},
|
||||
"Development": {
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Dev 模式:API 与云同步共用本地后端代理目标
|
||||
VITE_API_TARGET=http://localhost:5173
|
||||
@@ -0,0 +1,2 @@
|
||||
# MAUI 构建:云同步远端地址
|
||||
VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com
|
||||
@@ -0,0 +1,2 @@
|
||||
# Production 构建:云同步远端地址(proxy 在 build 时无效,此变量可由其他构建逻辑使用)
|
||||
VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import CloudSyncStorage from '../services/cloudSyncStorage';
|
||||
import { cloudSyncState } from '../services/cloudSyncState';
|
||||
|
||||
const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
|
||||
if ((window as any).showToast) {
|
||||
@@ -33,10 +32,17 @@ const cloudClient = axios.create({
|
||||
});
|
||||
|
||||
cloudClient.interceptors.request.use((config) => {
|
||||
// 根据运行模式决定 baseURL:
|
||||
// - Host 模式(Vite dev):使用同源请求,通过 Vite proxy 到 Host
|
||||
// - MAUI 模式:使用配置的 serverUrl 连接外部云同步服务器
|
||||
const isMaui = (window as any).__IS_MAUI__ === true;
|
||||
if (isMaui) {
|
||||
const { serverUrl } = CloudSyncStorage.loadSettings();
|
||||
if (serverUrl) {
|
||||
config.baseURL = serverUrl;
|
||||
}
|
||||
}
|
||||
// Host 模式下不设置 baseURL,axios 使用浏览器默认同源
|
||||
|
||||
const session = CloudSyncStorage.loadSession();
|
||||
if (session?.accessToken) {
|
||||
@@ -53,20 +59,23 @@ cloudClient.interceptors.response.use(
|
||||
let errorMessage = '网络错误,请稍后再试';
|
||||
|
||||
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') {
|
||||
// 捕获“需要二次认证”错误,触发二次认证弹窗,并中断当前请求(不弹普通错误提示)
|
||||
cloudSyncState.isSteppingUp = true;
|
||||
// 注意:这里我们暂不自动重试,让用户在弹窗输入密码后再手动触发(或由 UI 层处理重试)
|
||||
if (status === 403 && errorCode === 'FORBIDDEN') {
|
||||
// 缺少同步权限(例如 AllowSync = false):提示用户检查权限配置
|
||||
showNotification('云同步权限不足:请确认安全策略中已启用 AllowSync', 'error');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
if (status === 401) {
|
||||
CloudSyncStorage.clearSession();
|
||||
requestCloudSyncReLogin();
|
||||
errorMessage = status === 401 ? '云同步会话已过期,请重新登录' : '云同步权限不足,请重新登录或联系管理员';
|
||||
showNotification(errorMessage, 'error');
|
||||
showNotification('云同步会话已过期,请重新登录', 'error');
|
||||
} else {
|
||||
showNotification(`云同步请求被拒绝(${errorCode || status})`, 'error');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,18 +25,49 @@ export interface CloudTaskItem {
|
||||
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 {
|
||||
allowPersist: boolean;
|
||||
allowSync: boolean;
|
||||
requireSecondFactorFor: string[];
|
||||
}
|
||||
|
||||
export interface StepUpRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface StepUpResponse {
|
||||
stepUpExpiresAtUtc: string;
|
||||
/**
|
||||
* 服务端探测响应。
|
||||
*/
|
||||
export interface ProbeServerUrlResponse {
|
||||
isReachable: boolean;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 把前端的任务树结构扁平化为 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 最小闭环:登录 + 拉取任务)。
|
||||
*/
|
||||
@@ -93,9 +155,14 @@ export const cloudSyncApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空云同步会话(登出)。
|
||||
* 登出:通知服务端撤销会话并清空本地 Token。
|
||||
*/
|
||||
logout(): void {
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await cloudClient.post('/auth/logout');
|
||||
} catch {
|
||||
// 即使服务端请求失败也清空本地,避免残留无效 Token
|
||||
}
|
||||
CloudSyncStorage.clearSession();
|
||||
},
|
||||
|
||||
@@ -108,6 +175,22 @@ export const cloudSyncApi = {
|
||||
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> {
|
||||
const response = await cloudClient.post<StepUpResponse>('/auth/step-up', { password });
|
||||
async probeServerUrl(targetUrl: string): Promise<ProbeServerUrlResponse> {
|
||||
const response = await cloudClient.post<ProbeServerUrlResponse>('/cloud-sync/probe', { targetUrl });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export const taskApi = {
|
||||
async getTasks(completed?: boolean): Promise<ApiResponse<Task[]>> {
|
||||
if (!LocalStorageService.isOnline()) {
|
||||
const localTasks = LocalStorageService.loadTasks();
|
||||
LocalStorageService.syncTaskIdCounter(localTasks);
|
||||
return {
|
||||
success: true,
|
||||
data: completed !== undefined
|
||||
@@ -50,6 +51,8 @@ export const taskApi = {
|
||||
syncStatus.isOnline = true;
|
||||
syncStatus.pendingChanges = 0;
|
||||
LocalStorageService.saveSyncStatus(syncStatus);
|
||||
// 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突
|
||||
LocalStorageService.syncTaskIdCounter(apiResponse.data);
|
||||
}
|
||||
|
||||
return apiResponse;
|
||||
@@ -100,7 +103,7 @@ export const taskApi = {
|
||||
*/
|
||||
async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> {
|
||||
const newTask: Task = {
|
||||
id: Date.now(),
|
||||
id: LocalStorageService.getNextTaskId(),
|
||||
title: dto.title,
|
||||
priority: dto.priority,
|
||||
isCompleted: false,
|
||||
@@ -151,6 +154,8 @@ export const taskApi = {
|
||||
localTasks.unshift(apiResponse.data);
|
||||
}
|
||||
LocalStorageService.saveTasks(localTasks);
|
||||
// 用后端返回的真实 ID 同步计数器,保证后续新建任务 ID 始终大于已有任务
|
||||
LocalStorageService.syncTaskIdCounter(localTasks);
|
||||
}
|
||||
|
||||
return apiResponse;
|
||||
@@ -386,6 +391,8 @@ export const taskApi = {
|
||||
syncStatus.isOnline = true;
|
||||
syncStatus.pendingChanges = 0;
|
||||
LocalStorageService.saveSyncStatus(syncStatus);
|
||||
// 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突
|
||||
LocalStorageService.syncTaskIdCounter(apiResponse.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
<div class="section-title">同步开关</div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" v-model="localEnabled" @change="onToggleEnabled" />
|
||||
<span>启用云同步(v1.2.0:仅支持登录后拉取任务)</span>
|
||||
<span>启用云同步(支持双向同步)</span>
|
||||
</label>
|
||||
<div class="hint">
|
||||
启用后,主界面将以云端任务为准并进入只读展示;本地编辑/新增将在后续版本补齐。
|
||||
启用后,任务将与云端同步,支持双向同步功能。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,29 @@
|
||||
<div class="hint">
|
||||
已登录:<span class="mono">{{ sessionSummary }}</span>
|
||||
</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 class="policy-item">
|
||||
<span class="label">落盘策略:</span>
|
||||
@@ -63,8 +86,9 @@
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,99 +124,186 @@
|
||||
<button class="btn btn-secondary" @click="close">关闭</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 云同步设置对话框组件
|
||||
*
|
||||
* 提供云同步功能的配置界面,包括:
|
||||
* - 启用/禁用云同步开关
|
||||
* - 配置和探测服务端地址
|
||||
* - 用户登录/登出
|
||||
* - 查看安全策略(落盘策略、同步权限)
|
||||
* - 二次认证(Step-up Authentication)
|
||||
* - 拉取云端任务
|
||||
*
|
||||
* 与后端交互通过 cloudSyncApi 完成,状态通过 CloudSyncStorage 持久化。
|
||||
* 通过 CustomEvent 与其他组件通信(cloudSyncStateChanged、cloudSyncPullTasksRequested)。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import CloudSyncStorage from '../services/cloudSyncStorage';
|
||||
import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync';
|
||||
import LocalStorageService from '../services/localStorageService';
|
||||
import LocalStorageService, { type SyncStatus } from '../services/localStorageService';
|
||||
import { cloudSyncState } from '../services/cloudSyncState';
|
||||
|
||||
/** 探测结果的类型 */
|
||||
type ProbeType = 'success' | 'warn' | 'error';
|
||||
|
||||
/** 服务端探测结果 */
|
||||
interface ProbeResult {
|
||||
/** 结果类型:成功/警告/错误 */
|
||||
type: ProbeType;
|
||||
/** 结果标题 */
|
||||
title: string;
|
||||
/** 结果详细描述 */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
// ========== UI 状态 ==========
|
||||
|
||||
/** 对话框是否打开 */
|
||||
const isOpen = ref(false);
|
||||
|
||||
/** 云同步是否启用(本地状态) */
|
||||
const localEnabled = ref(false);
|
||||
|
||||
/** 服务端地址输入框内容 */
|
||||
const serverUrlInput = ref('');
|
||||
|
||||
/** 已保存的服务端地址 */
|
||||
const serverUrlSaved = ref('');
|
||||
|
||||
/** 是否正在探测服务端 */
|
||||
const isProbing = ref(false);
|
||||
|
||||
/** 探测结果 */
|
||||
const probeResult = ref<ProbeResult | null>(null);
|
||||
|
||||
// ========== 登录状态 ==========
|
||||
|
||||
/** 登录用户名 */
|
||||
const userName = ref('');
|
||||
|
||||
/** 登录密码 */
|
||||
const password = ref('');
|
||||
|
||||
/** 是否正在登录中 */
|
||||
const isLoggingIn = ref(false);
|
||||
|
||||
/** 安全策略(从服务端获取) */
|
||||
const 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 = () => {
|
||||
refreshSession();
|
||||
const settings = CloudSyncStorage.loadSettings();
|
||||
localEnabled.value = settings.enabled;
|
||||
serverUrlSaved.value = settings.serverUrl;
|
||||
serverUrlInput.value = settings.serverUrl;
|
||||
|
||||
refreshSyncStatus(); // 加载同步状态
|
||||
if (isLoggedIn.value) {
|
||||
refreshPolicy();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新安全策略
|
||||
* 从服务端获取最新的安全策略,并同步到 cloudSyncState 和 LocalStorageService
|
||||
*
|
||||
* @throws 获取失败时输出错误日志,不中断流程
|
||||
*/
|
||||
const refreshPolicy = async () => {
|
||||
try {
|
||||
const p = await cloudSyncApi.getSecurityPolicy();
|
||||
policy.value = p;
|
||||
cloudSyncState.policy = p;
|
||||
// 更新落盘策略
|
||||
// 更新落盘策略:控制是否允许本地持久化存储
|
||||
LocalStorageService.setAllowPersist(p.allowPersist);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch security policy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(CloudSyncStorage.loadSession()?.accessToken));
|
||||
const sessionSummary = computed(() => {
|
||||
const s = CloudSyncStorage.loadSession();
|
||||
if (!s) return '';
|
||||
const shortUserId = s.userId ? s.userId.slice(0, 8) : '';
|
||||
return `${s.role || 'User'}@${shortUserId}`;
|
||||
});
|
||||
/**
|
||||
* 刷新同步状态
|
||||
* 从 LocalStorageService 加载最新的同步状态
|
||||
*/
|
||||
const refreshSyncStatus = () => {
|
||||
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(() => {
|
||||
if (!localEnabled.value) return false;
|
||||
if (!serverUrlSaved.value) return false;
|
||||
@@ -200,6 +311,13 @@ const canLogin = computed(() => {
|
||||
return !isLoggingIn.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 显示 Toast 消息
|
||||
* 优先使用全局 showToast 函数,降级使用 window.alert
|
||||
*
|
||||
* @param message - 消息内容
|
||||
* @param type - 消息类型:error 或 success
|
||||
*/
|
||||
const showToast = (message: string, type: 'error' | 'success' = 'error') => {
|
||||
if ((window as any).showToast) {
|
||||
(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 trimmed = raw.trim();
|
||||
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 };
|
||||
};
|
||||
|
||||
const probeReachability = async (serverUrl: string): Promise<ProbeResult> => {
|
||||
const u = new URL(serverUrl);
|
||||
const isHttps = u.protocol === 'https:';
|
||||
const loginUrl = `${serverUrl}/auth/login`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const res = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userName: '__probe__', password: '__probe__' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!isHttps) {
|
||||
return {
|
||||
type: 'warn',
|
||||
title: '可达(存在风险)',
|
||||
desc: '服务端可访问,但当前为 HTTP(非加密)。建议使用 HTTPS。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
title: '可达',
|
||||
desc: `已探测到服务端响应(HTTP ${res.status})。可继续登录。`,
|
||||
};
|
||||
} catch {
|
||||
if (!isHttps) {
|
||||
return {
|
||||
type: 'warn',
|
||||
title: '存在风险',
|
||||
desc: '当前为 HTTP 且探测失败。请检查地址是否可达,或改用 HTTPS。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'warn',
|
||||
title: '存在风险',
|
||||
desc: '探测失败(可能是网络不可达 / 证书异常 / 跨域被阻止)。仍可保存地址并稍后重试登录。',
|
||||
};
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 触发云同步状态变更事件
|
||||
* 通知其他组件云同步设置已更新
|
||||
*/
|
||||
const emitCloudSyncStateChanged = () => {
|
||||
const settings = CloudSyncStorage.loadSettings();
|
||||
const session = CloudSyncStorage.loadSession();
|
||||
@@ -292,6 +374,12 @@ const emitCloudSyncStateChanged = () => {
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换云同步启用状态
|
||||
* 启用前会校验是否已配置服务端地址
|
||||
*
|
||||
* @fires cloudSyncStateChanged - 状态变更后触发
|
||||
*/
|
||||
const onToggleEnabled = () => {
|
||||
if (localEnabled.value) {
|
||||
if (!serverUrlSaved.value) {
|
||||
@@ -308,6 +396,12 @@ const onToggleEnabled = () => {
|
||||
emitCloudSyncStateChanged();
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存服务端地址并探测连接
|
||||
* 会先规范化地址,保存到本地存储,然后调用后端探测接口
|
||||
*
|
||||
* @fires cloudSyncStateChanged - 保存后触发
|
||||
*/
|
||||
const saveServerUrl = async () => {
|
||||
const parsed = normalizeServerUrl(serverUrlInput.value);
|
||||
if (!parsed.ok || !parsed.normalized) {
|
||||
@@ -329,7 +423,19 @@ const saveServerUrl = async () => {
|
||||
|
||||
isProbing.value = true;
|
||||
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 {
|
||||
isProbing.value = false;
|
||||
}
|
||||
@@ -337,6 +443,12 @@ const saveServerUrl = async () => {
|
||||
emitCloudSyncStateChanged();
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行登录
|
||||
* 调用 cloudSyncApi.login 完成登录,登录成功后刷新安全策略
|
||||
*
|
||||
* @fires cloudSyncStateChanged - 登录成功后触发
|
||||
*/
|
||||
const login = async () => {
|
||||
if (!canLogin.value) return;
|
||||
isLoggingIn.value = true;
|
||||
@@ -346,70 +458,76 @@ const login = async () => {
|
||||
password: password.value,
|
||||
});
|
||||
showToast('登录成功', 'success');
|
||||
refreshSession();
|
||||
await refreshPolicy();
|
||||
emitCloudSyncStateChanged();
|
||||
await pullTasks();
|
||||
} finally {
|
||||
isLoggingIn.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
cloudSyncApi.logout();
|
||||
/**
|
||||
* 执行登出
|
||||
* 清除本地会话和安全策略,重置落盘策略为允许
|
||||
*
|
||||
* @fires cloudSyncStateChanged - 登出后触发
|
||||
*/
|
||||
const logout = async () => {
|
||||
await cloudSyncApi.logout();
|
||||
refreshSession();
|
||||
policy.value = null;
|
||||
cloudSyncState.policy = { allowPersist: true, allowSync: true, requireSecondFactorFor: [] };
|
||||
cloudSyncState.policy = { allowPersist: true, allowSync: true };
|
||||
LocalStorageService.setAllowPersist(true); // 登出后重置
|
||||
showToast('已登出', 'success');
|
||||
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 {
|
||||
await cloudSyncApi.stepUp(stepUpPassword.value);
|
||||
showToast('二次认证成功', 'success');
|
||||
cloudSyncState.isSteppingUp = false;
|
||||
stepUpPassword.value = '';
|
||||
// 如果有挂起的回调,执行它
|
||||
if (cloudSyncState.onStepUpSuccess) {
|
||||
cloudSyncState.onStepUpSuccess();
|
||||
cloudSyncState.onStepUpSuccess = null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
stepUpError.value = '认证失败,请检查密码';
|
||||
} finally {
|
||||
isSteppingUp.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelStepUp = () => {
|
||||
cloudSyncState.isSteppingUp = false;
|
||||
cloudSyncState.onStepUpSuccess = null;
|
||||
stepUpPassword.value = '';
|
||||
stepUpError.value = '';
|
||||
};
|
||||
|
||||
const pullTasks = async () => {
|
||||
const event = new CustomEvent('cloudSyncPullTasksRequested');
|
||||
refreshSyncStatus(); // 先更新状态
|
||||
const event = new CustomEvent('cloudSyncTasksRequested');
|
||||
window.dispatchEvent(event);
|
||||
// 延迟刷新状态,等待同步完成
|
||||
setTimeout(() => refreshSyncStatus(), 500);
|
||||
} finally {
|
||||
// 保持 isSyncing 状态一段时间,让用户看到反馈
|
||||
setTimeout(() => {
|
||||
isSyncing.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开设置对话框
|
||||
* 加载本地状态并重置探测结果
|
||||
*/
|
||||
function handleOpenSettings() {
|
||||
loadLocalState();
|
||||
probeResult.value = null;
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
/** 关闭对话框 */
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
|
||||
onMounted(() => {
|
||||
// 监听打开设置对话框事件
|
||||
window.addEventListener('openCloudSyncSettings', handleOpenSettings);
|
||||
|
||||
// 初始化:如果已登录,自动获取策略
|
||||
// 初始化:加载同步状态,如果已登录则获取策略
|
||||
refreshSyncStatus();
|
||||
if (isLoggedIn.value) {
|
||||
refreshPolicy();
|
||||
}
|
||||
@@ -557,6 +675,41 @@ onMounted(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -633,15 +786,6 @@ onMounted(() => {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.step-up-overlay {
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.step-up-dialog {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
@@ -682,4 +826,16 @@ onMounted(() => {
|
||||
background: #f3f4f6;
|
||||
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>
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="task.isCompleted"
|
||||
:disabled="readOnly"
|
||||
@change="toggleComplete"
|
||||
class="task-checkbox"
|
||||
/>
|
||||
<button v-if="task.subTasks && task.subTasks.length > 0" class="task-expand" @click.stop="toggleExpand" :title="isExpanded ? '收起' : '展开'">
|
||||
<span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span>
|
||||
</button>
|
||||
<button 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">
|
||||
<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>
|
||||
@@ -25,13 +24,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20h9" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -71,7 +70,6 @@
|
||||
v-for="subTask in task.subTasks"
|
||||
:key="subTask.id"
|
||||
:task="subTask"
|
||||
:readOnly="readOnly"
|
||||
@updated="handleSubTaskUpdated"
|
||||
@deleted="handleSubTaskDeleted"
|
||||
@subtask-created="$emit('subtask-created', $event)"
|
||||
@@ -94,11 +92,9 @@ import type { Task } from '../types/task';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const readOnly = computed(() => props.readOnly === true);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated', task: Task): void;
|
||||
@@ -114,10 +110,6 @@ const newSubTaskPriority = ref<0 | 1 | 2>(1);
|
||||
const subTaskInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const startAddSubTask = async () => {
|
||||
if (readOnly.value) {
|
||||
notifyReadOnly();
|
||||
return;
|
||||
}
|
||||
showAddSubTask.value = true;
|
||||
await nextTick();
|
||||
if (subTaskInput.value) {
|
||||
@@ -160,10 +152,6 @@ const toggleExpand = () => {
|
||||
};
|
||||
|
||||
const toggleComplete = async () => {
|
||||
if (readOnly.value) {
|
||||
notifyReadOnly();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await taskApi.toggleComplete(props.task.id);
|
||||
if (response.success && response.data) {
|
||||
@@ -175,10 +163,6 @@ const toggleComplete = async () => {
|
||||
};
|
||||
|
||||
const deleteTask = async () => {
|
||||
if (readOnly.value) {
|
||||
notifyReadOnly();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await taskApi.deleteTask(props.task.id);
|
||||
if (response.success) {
|
||||
@@ -190,10 +174,6 @@ const deleteTask = async () => {
|
||||
};
|
||||
|
||||
const createSubTask = async () => {
|
||||
if (readOnly.value) {
|
||||
notifyReadOnly();
|
||||
return;
|
||||
}
|
||||
if (!newSubTaskTitle.value.trim()) {
|
||||
alert('请输入子任务标题');
|
||||
return;
|
||||
@@ -235,24 +215,12 @@ const handleSubTaskDeleted = (subTaskId: number) => {
|
||||
};
|
||||
|
||||
const openEditDialog = () => {
|
||||
if (readOnly.value) {
|
||||
notifyReadOnly();
|
||||
return;
|
||||
}
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const handleTaskSaved = (updatedTask: Task) => {
|
||||
emit('updated', updatedTask);
|
||||
};
|
||||
|
||||
const notifyReadOnly = () => {
|
||||
if ((window as any).showToast) {
|
||||
(window as any).showToast('云同步模式为只读展示,编辑/新增将在后续版本补齐', 'error');
|
||||
} else {
|
||||
window.alert('云同步模式为只读展示,编辑/新增将在后续版本补齐');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -74,6 +74,18 @@
|
||||
</svg>
|
||||
<span>云同步</span>
|
||||
</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 class="task-info">
|
||||
@@ -86,7 +98,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isReadOnly" class="task-form">
|
||||
<div class="task-form">
|
||||
<input
|
||||
v-model="newTaskTitle"
|
||||
type="text"
|
||||
@@ -106,9 +118,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="cloud-readonly-banner">
|
||||
云同步模式:只读展示(v1.2.0)。编辑/新增将在后续版本补齐。
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTasks.length === 0" class="empty">
|
||||
{{ searchQuery.trim().length > 0 ? '无匹配任务' : '暂无任务' }}
|
||||
@@ -120,7 +129,6 @@
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:readOnly="isReadOnly"
|
||||
@updated="handleTaskUpdated"
|
||||
@deleted="handleTaskDeleted"
|
||||
@subtask-created="handleSubTaskCreated"
|
||||
@@ -155,6 +163,15 @@ const searchQuery = ref('');
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const isCloudSyncEnabled = 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 = () => {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
@@ -245,7 +262,7 @@ const filteredTasks = computed(() => {
|
||||
return sortTasks(searchFilteredRootTasks);
|
||||
});
|
||||
|
||||
const isReadOnly = computed(() => isCloudSyncEnabled.value && isCloudLoggedIn.value);
|
||||
const isReadOnly = computed(() => false);
|
||||
|
||||
const cloudStatusLabel = computed(() => {
|
||||
if (!isCloudSyncEnabled.value) return '';
|
||||
@@ -369,6 +386,7 @@ const createTask = async () => {
|
||||
tasks.value.unshift(response.data);
|
||||
newTaskTitle.value = '';
|
||||
newTaskPriority.value = 1;
|
||||
lastSyncTime.value = Date.now();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
@@ -378,6 +396,7 @@ const createTask = async () => {
|
||||
|
||||
const handleTaskUpdated = (updatedTask: Task) => {
|
||||
updateTaskInTree(tasks.value, updatedTask);
|
||||
lastSyncTime.value = Date.now();
|
||||
};
|
||||
|
||||
const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
|
||||
@@ -397,6 +416,7 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
|
||||
|
||||
const handleTaskDeleted = (taskId: number) => {
|
||||
deleteTaskFromTree(tasks.value, taskId);
|
||||
lastSyncTime.value = Date.now();
|
||||
};
|
||||
|
||||
const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => {
|
||||
@@ -416,6 +436,7 @@ const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => {
|
||||
|
||||
const handleSubTaskCreated = (subTask: Task) => {
|
||||
addSubTaskToTree(tasks.value, subTask);
|
||||
lastSyncTime.value = Date.now();
|
||||
};
|
||||
|
||||
const addSubTaskToTree = (tasks: Task[], subTask: Task): boolean => {
|
||||
@@ -463,9 +484,44 @@ const handleCloudSyncStateChanged = () => {
|
||||
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();
|
||||
loadTasks();
|
||||
syncNow();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -475,14 +531,14 @@ onMounted(() => {
|
||||
window.addEventListener('online', handleOnlineStatus);
|
||||
window.addEventListener('offline', handleOnlineStatus);
|
||||
window.addEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
|
||||
window.addEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener);
|
||||
window.addEventListener('cloudSyncTasksRequested', handleCloudSyncTasksRequested as EventListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', handleOnlineStatus);
|
||||
window.removeEventListener('offline', handleOnlineStatus);
|
||||
window.removeEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
|
||||
window.removeEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener);
|
||||
window.removeEventListener('cloudSyncTasksRequested', handleCloudSyncTasksRequested as EventListener);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -897,6 +953,23 @@ onUnmounted(() => {
|
||||
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 {
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -17,18 +17,7 @@ export const cloudSyncState = reactive({
|
||||
policy: {
|
||||
allowPersist: true,
|
||||
allowSync: true,
|
||||
requireSecondFactorFor: [] as string[],
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否正在进行二次认证流程。
|
||||
*/
|
||||
isSteppingUp: false,
|
||||
|
||||
/**
|
||||
* 二次认证成功后的回调(用于重试之前失败的操作)。
|
||||
*/
|
||||
onStepUpSuccess: null as (() => void) | null,
|
||||
});
|
||||
|
||||
export default cloudSyncState;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normalizeTasks } from './taskNormalizer';
|
||||
|
||||
const STORAGE_KEY = 'Hua.Todo_tasks';
|
||||
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 = {
|
||||
tasks: [] as Task[],
|
||||
syncStatus: null as SyncStatus | null,
|
||||
nextTaskId: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,18 @@ export class LocalStorageService {
|
||||
if (previous && !allowed) {
|
||||
this.clearTasks();
|
||||
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.');
|
||||
} 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 {
|
||||
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) {
|
||||
console.error('Failed to load tasks from localStorage:', error);
|
||||
return [];
|
||||
@@ -113,6 +129,79 @@ export class LocalStorageService {
|
||||
static isOnline(): boolean {
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
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 }) => {
|
||||
// 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, '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 {
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
@@ -15,13 +32,43 @@ export default defineConfig(({ mode }) => {
|
||||
emptyOutDir: true,
|
||||
},
|
||||
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: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
// 本地 API 代理到后端
|
||||
'/api': {
|
||||
target: 'http://localhost:5173',
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user