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

This commit is contained in:
ShaoHua
2026-06-12 00:49:03 +08:00
parent c54f2e2ecc
commit a7ba814833
34 changed files with 1532 additions and 471 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+90 -45
View File
@@ -1,30 +1,27 @@
# Hua.Todo 跨平台代办管理应用 v1.2.8
一个基于 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 (新增) │
└───────────────────────────────┘
```
**模式 BMAUI 嵌入式 WebView(桌面端/移动端)**
```
┌───────────────────────────────┐
│ MAUI WebView │
│ ┌─────────────────────────┐ │
│ │ Vue 前端 (静态托管) │ │
│ │ cloudClient │ │
│ │ baseURL = 同源 │ │ 同源请求
│ │ → /api/* (本地任务) │──┼───────→ Embedded WebServer
│ │ → /auth/* (❌ 不可用) │ │ (不暴露云同步端点)
│ └─────────────────────────┘ │
├───────────────────────────────┤
│ Embedded WebServer (:5057) │
│ AddApplicationServices() │ 仅任务 CRUD
│ AddCloudSyncServer() │ ❌ 未注册
│ UseDynamicApi() │ 仅本地任务 API
│ MapCloudSyncEndpoints() │ ❌ 未映射
└───────────────────────────────┘
```
> **关键区别**`Hua.Todo.Application` 被两方引用,但云同步能力**仅在 Host 端通过 `AddCloudSyncServer()` 激活**。MAUI 端只使用 `AddApplicationServices()` 获取任务 CRUD 能力,**不暴露也不应暴露云同步端点**。
#### 1.3 核心问题
1. 前端的"保存并探测"使用**浏览器端直接 `fetch()`** 探测外部 URL,受 CORS、证书、网络隔离限制。
2. 前端的"登录"通过 `cloudClient` 直连外部 `serverUrl`session token 存在前端 `sessionStorage`,与 Host 脱节。
3. 在 Host 模式下,Host 本身就是云同步服务,但前端并不知道,额外配置了外部 `serverUrl`
### 2. 改造目标
| 操作 | 当前 | 目标 |
|---|---|---|
| **保存并探测** | 浏览器 `fetch()` 直连 | 前端 → Host 接口 → Application CloudProbeService → 返回结果 |
| **登录** | 前端直连 `serverUrl` | 前端 → Host `/auth/login` |
| **后续云同步请求** | `cloudClient` 直连 `serverUrl` | 统一走 Host(同源请求) |
### 3. 变更详情
#### 3.1 新增:服务端探测接口
> **注意**`CloudProbeService` 和服务端点注册均在 `AddCloudSyncServer()` 路径下,MAUI 端不受影响。
**Application 层新增 `CloudProbeService`**
位置:`src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs`
职责:接收目标 URL,从服务端发起 HTTP 探测,返回探测结果。
**新增 DTO**(位置:`src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs`,或新建 `CloudSync/Models/ProbeDtos.cs`
DTO 是纯数据合约,放在 Application 共享层无副作用,MAUI 端即使不调用也不会产生依赖。
```csharp
/// <summary>
/// 服务端探测请求。
/// </summary>
public class ProbeRequest
{
public string TargetUrl { get; set; } = string.Empty;
}
/// <summary>
/// 服务端探测响应。
/// </summary>
public class ProbeResponse
{
public bool IsReachable { get; set; }
public int? HttpStatus { get; set; }
public bool IsHttps { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>
/// success | warn | error
/// </summary>
public string Type { get; set; } = "error";
}
```
**DI 注册(`CloudSyncServiceCollectionExtensions.AddCloudSyncServer()`**
`AddCloudSyncServer()` 方法末尾追加,确保 `CloudProbeService` 仅在服务端 DI 容器中可用:
```csharp
// CloudSyncServiceCollectionExtensions.AddCloudSyncServer() 末尾新增:
services.AddHttpClient("ProbeClient", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
});
services.AddScoped<CloudProbeService>();
```
> **为什么放在 `AddCloudSyncServer()` 而非 `AddApplicationServices()`**
> - `AddApplicationServices()` 被 MAUI 端调用,MAUI 不需要也不会启动云同步探测
> - `AddCloudSyncServer()` 仅在 Host 的 `Program.cs` 中被调用,确保服务仅注册在服务端
**端点注册(`CloudSyncEndpointExtensions.MapCloudSyncEndpoints()`**
`MapCloudSyncEndpoints()` 方法内部新增端点组:
```csharp
var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup");
probe.MapPost("/probe", ProbeAsync).AllowAnonymous();
```
端点实现:
```csharp
private static async Task<IResult> ProbeAsync(
ProbeRequest request,
CloudProbeService probeService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl))
{
return CloudApiErrors.BadRequest("TargetUrl is required.");
}
var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken);
return Results.Json(result);
}
```
**前端修改** `src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue`
删除 `probeReachability()` 函数(原 L235-L280),`saveServerUrl()` 中改为:
```typescript
// 旧:前端直接 fetch
// probeResult.value = await probeReachability(serverUrlSaved.value);
// 新:调用 Host 的探测接口
const response = await cloudClient.post<ProbeResponse>('/cloud-sync/probe', {
targetUrl: serverUrlSaved.value,
});
probeResult.value = {
type: response.data.type as ProbeType,
title: response.data.title,
desc: response.data.description,
};
```
#### 3.2 修改:登录统一走 Host
**核心思路**Host 自身就是云同步服务方,`/auth/login` 已实现完整认证。前端只需改为走 Host 同源请求。
**前端 `cloudClient.ts` 改造**`src/Hua.Todo.Web/src/api/cloudClient.ts`
移除对外部 `serverUrl` 的依赖:
```typescript
// 改造前(L36-L39):
// const { serverUrl } = CloudSyncStorage.loadSettings();
// if (serverUrl) { config.baseURL = serverUrl; }
// 改造后:不设 baseURL,axios 使用浏览器默认同源
// cloudClient 请求直接打到当前 Host/auth/* , /tasks/* , /cloud-sync/*
```
> **同源适配说明**
> - **Host 模式**Vite dev):Vue 运行在 :5174`/api` 走 proxy 到 Host(:5173),云同步端点 `/auth/*` 需配 Vite proxy 或直接用 Host 地址
> - **MAUI 模式**WebView):Vue 部署在 MAUI 嵌入服务器同源,不需要额外代理
> - 具体方案:在 Vite dev 时增加 proxy 规则将 `/auth`、`/tasks`、`/cloud-sync` 也 proxy 到 Host;或在 cloudClient 中按环境设置 baseURL
**前端 `CloudSyncSettingsDialog.vue` 的 `login()` 无需修改**
`cloudSyncApi.login()` 调用 `cloudClient.post('/auth/login', ...)`,自动打到 Host。
**Host 端 `CloudAuthService.LoginAsync` 已有完整实现**
- 创建 `UserSessionEntity` 写入 DBsession 管理在 Host
- 返回 `LoginResponse``AccessToken` = DB SessionId
- Session 验证由 `SessionAuthenticationHandler` 查询 DB 完成
#### 3.3 Vite 代理配置补充
Vite dev 模式下,除了已有的 `/api` 代理,需**补充云同步端点的代理规则**(`vite.config.ts`):
```typescript
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
// 新增:云同步端点代理到 Host
'/auth': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/tasks': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/sync': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/security': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/cloud-sync': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
},
},
```
> **MAUI 模式无需此配置**MAUI 使用 `vite build --mode maui`,静态部署到嵌入服务器同源。
#### 3.4 `serverUrl` 字段的语义变化
| 维度 | 改造前 | 改造后 |
|---|---|---|
| **用途** | 云同步 API 的 base URL(前端直连) | 仅用于"服务端探测"的目标地址 |
| **存储位置** | `localStorage` | 不变(探测时使用) |
| **影响登录/任务拉取** | 是(前端用它做 baseURL) | 否(走 Host 自身端点) |
### 4. 需要变更的文件清单
| 层 | 文件 | 变更类型 | 说明 |
|---|---|---|---|
| Application | `Services/CloudProbeService.cs` | **新增** | 服务端探测逻辑,注册于 `AddCloudSyncServer()` |
| Application | `Models/AdminDtos.cs`(或新 `Models/ProbeDtos.cs` | **新增 DTO** | `ProbeRequest` / `ProbeResponse`(纯数据合约) |
| Application | `CloudSyncEndpointExtensions.cs` | **修改** | 注册 `POST /cloud-sync/probe` |
| Application | `CloudSyncServiceCollectionExtensions.cs` | **修改** | `AddCloudSyncServer()` 内注册 `CloudProbeService` + `HttpClient` |
| Host | `Program.cs` | **无需修改** | `MapCloudSyncEndpoints()` 自动包含新端点 |
| Web(Vue) | `vite.config.ts` | **修改** | 补充云同步端点的 dev proxy |
| Web(Vue) | `api/cloudClient.ts` | **修改** | 移除外部 `serverUrl` baseURL |
| Web(Vue) | `api/cloudSync.ts` | **修改** | 新增 `probeServerUrl()` 方法 |
| Web(Vue) | `components/CloudSyncSettingsDialog.vue` | **修改** | 删除 `probeReachability()`,改用 API 调用 |
| 层 | 文件 | 变更 | 说明 |
|---|---|---|---|
| MAUI | `MauiProgram.cs` | **无需修改** | 不注册 `AddCloudSyncServer()` |
| MAUI | `EmbeddedWebServerService.cs` | **无需修改** | 不映射 `MapCloudSyncEndpoints()` |
### 5. 端点路由变更汇总
| 路由 | 方法 | 变更 | 可用环境 |
|---|---|---|---|
| `/cloud-sync/probe` | POST | **新增** | HostVite dev proxy/ MAUI(同源请求到 Host |
| `/auth/login` | POST | 无变更 | 同上 |
| `/auth/step-up` | POST | 无变更 | 同上 |
| `/tasks/` | GET | 无变更 | 同上 |
| `/security/policy` | GET | 无变更 | 同上 |
### 6. 服务端/客户端职责边界总结
```
┌──────────────────────────────────────────────────────────────┐
│ Hua.Todo.Application (共享层) │
│ │
│ AddApplicationServices() AddCloudSyncServer() │
│ ├─ TodoDbContext ├─ CloudAuthService │
│ ├─ TaskRepository ├─ CloudAdminService │
│ ├─ TaskService ├─ CloudTaskSyncService │
│ ├─ DynamicApi ├─ SecurityPolicyService │
│ └─ DTOs (Models/*) ├─ CloudProbeService (新) │
│ ↑ 纯数据合约,两端安全 ├─ Authentication/Policy │
│ └─ MapCloudSyncEndpoints │
│ │
│ Host 注册: AddApplicationServices + AddCloudSyncServer │
│ MAUI 注册: AddApplicationServices only │
└──────────────────────────────────────────────────────────────┘
```
### 7. 安全考量
| 风险点 | 缓解措施 |
|---|---|
| SSRF(探测端点攻击内网) | `CloudProbeService` 限制目标 URL 必须是 HTTP/HTTPS 公网地址,禁止探测 localhost/内网 IP |
| 探测请求被滥用 | 加频率限制(如每分钟 3 次),仅允许同源请求 |
| MAUI 端不暴露云同步端点 | `AddCloudSyncServer()` 仅在 Host 调用,MAUI 无法访问云同步端点 |
+71
View File
@@ -0,0 +1,71 @@
param(
[switch]$Init,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Install-CodeGraph {
Write-Host "[信息] 正在安装 codegraph…" -ForegroundColor Cyan
$npm = Get-Command npm -ErrorAction SilentlyContinue
if ($npm) {
Write-Host "[信息] 通过 npm 安装 @colbymchenry/codegraph…" -ForegroundColor Cyan
npm install -g @colbymchenry/codegraph
if ($LASTEXITCODE -eq 0) {
Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green
return
}
Write-Host "[警告] npm 安装失败,尝试使用官方安装脚本…" -ForegroundColor Yellow
}
Write-Host "[信息] 通过官方脚本安装…" -ForegroundColor Cyan
irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
if ($LASTEXITCODE -ne 0) { throw "codegraph 安装失败" }
Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green
}
# 切换到上级目录(脚本所在目录的上级)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$parentDir = Split-Path -Parent $scriptDir
Push-Location $parentDir
Write-Host "[信息] 工作目录已切换到: $parentDir" -ForegroundColor Cyan
try {
$codegraph = Get-Command codegraph -ErrorAction Stop
Write-Host "[正常] 已检测到 codegraph CLI" -ForegroundColor Green
} catch {
Write-Host "[提示] 未找到 codegraph,将自动安装…" -ForegroundColor Yellow
Install-CodeGraph
}
if (-not (Test-Path ".\.codegraph")) {
Write-Host "[警告] 未找到 .codegraph/ 目录,正在执行初始化…" -ForegroundColor Yellow
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 初始化 + 索引构建完毕" -ForegroundColor Green
} elseif ($Init) {
Write-Host "[信息] 正在重新初始化…" -ForegroundColor Cyan
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 重新初始化 + 索引构建完毕" -ForegroundColor Green
} elseif ($Force) {
Write-Host "[信息] 正在执行全量重建索引…" -ForegroundColor Cyan
codegraph index --force
if ($LASTEXITCODE -ne 0) { throw "codegraph index --force 失败" }
Write-Host "[完成] 全量重建索引完毕" -ForegroundColor Green
} else {
Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan
codegraph sync
if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" }
Write-Host "[完成] 增量同步完毕" -ForegroundColor Green
}
Write-Host "`n--- CodeGraph 状态 ---" -ForegroundColor Cyan
codegraph status
if ($LASTEXITCODE -ne 0) {
Write-Host "[警告] codegraph status 返回异常" -ForegroundColor Yellow
}
Pop-Location
@@ -40,14 +40,5 @@ public static class ClaimsPrincipalExtensions
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
}
/// <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>
+2 -2
View File
@@ -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": {
},
+2
View File
@@ -0,0 +1,2 @@
# Dev 模式:API 与云同步共用本地后端代理目标
VITE_API_TARGET=http://localhost:5173
+2
View File
@@ -0,0 +1,2 @@
# MAUI 构建:云同步远端地址
VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com
+2
View File
@@ -0,0 +1,2 @@
# Production 构建:云同步远端地址(proxy 在 build 时无效,此变量可由其他构建逻辑使用)
VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com
+17 -8
View File
@@ -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 模式下不设置 baseURLaxios 使用浏览器默认同源
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);
}
+96 -12
View File
@@ -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;
},
};
+8 -1
View File
@@ -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>
+3 -35
View File
@@ -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>
+83 -10
View File
@@ -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;
+51 -4
View File
@@ -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,
},