Compare commits

..

2 Commits

Author SHA1 Message Date
ShaoHua 7a4c516a20 feat: 引入 CloudSync 核心能力并新增 Avalonia 桌面端与发布脚本
- 后端:新增 CloudSync 认证/权限/端点/服务与 DTO
- 数据:新增用户/会话/安全策略实体与 EF Core migrations
- 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot
- 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键
- 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置
- 文档:同步更新 README/docs 与协作规则
2026-04-07 03:34:34 +08:00
ShaoHua 18d37fdd24 doc:输出1.2.0版本需求文档 2026-04-07 00:36:39 +08:00
90 changed files with 6186 additions and 115 deletions
+2
View File
@@ -366,3 +366,5 @@ FodyWeavers.xsd
/Hua.Todo/Output
/src/Hua.Todo.Maui/Output
/src/Hua.Todo.Host/Hua.Todo.db
/src/Hua.Todo.Host/Hua.Todo.db-shm
/src/Hua.Todo.Host/Hua.Todo.db-wal
+18
View File
@@ -0,0 +1,18 @@
# 协调目录(并行 solo 专用)
该目录用于解决两类问题:
- 文件冲突:多窗口并行时明确“谁是 Writer”,其他人不直接改同一文件
- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进
## 目录约定
- `ownership.md`:文件/目录所有权登记(Writer 表)
- `shared-files.md`:本阶段共享文件清单(由 Integrator 维护)
- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘)
- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件)
## 使用规则
- 所有权与共享文件清单优先使用“仓库相对路径”
- 禁止记录或提交构建产物目录中的文件路径(如 `bin/``obj/``node_modules/``dist/` 等)
+13
View File
@@ -0,0 +1,13 @@
# 文件/目录所有权(Writer)登记
规则:
- 同一时段内,同一个文件只能有一个 Writer
- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘
- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码
## 当前所有权
| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 |
|---|---|---|---|
| `src\<module>\<file>` | `<name>` | `<task-id>` | `共享:是/否` |
+11
View File
@@ -0,0 +1,11 @@
# 本阶段共享文件清单(Integrator 维护)
规则:
- 本文件只由 Integrator 修改,避免反复冲突
- 清单内每条必须是“仓库相对路径”,并说明为什么共享(入口/协议/配置/依赖锁等)
- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator
## 共享文件
- `src\<module>\<file>``<why-shared>`
@@ -0,0 +1,99 @@
---
alwaysApply: false
description:
---
# 并行 solo 窗口冲突规约(必须遵守)
## 适用范围
- 当同一个版本/需求被拆分为多个并行任务,并由多个 solo 窗口同时推进时适用。
- 目标是同时降低两类风险:
- **文件冲突**:多人同时改同一文件/相邻行导致冲突。
- **编译区间冲突**:A 窗口引入的未完成变更破坏编译,阻塞 B 窗口集成与验证。
## 核心原则
- **先声明后修改**:任何代码改动前,先在任务文档中声明“触碰文件清单(Touch List)”与“共享文件策略”。
- **文件所有权唯一**:同一时段内,一个文件只能被一个窗口作为“写入者(Writer)”修改。
- **共享文件单点修改**:涉及高耦合/共享入口的改动,集中到一个“集成窗口(Integrator)”完成,其他窗口只做准备工作(新文件/独立模块/文档/测试)。
- **绿线优先(可编译)**:任何可落盘、可合入的变更必须保持可编译;临时状态必须通过“隔离手段”而不是破坏编译来实现。
## Touch List(触碰文件清单)
- 每个并行任务 md 必须在开头包含一个明确的 Touch List,至少包含:
- 新增/修改/删除的文件路径(精确到文件,必须写“准确目录”)
- 预期修改类型(新增/小改/重构/接口变更/配置变更)
- 是否为共享文件(是/否)
- Touch List 必须保持可检索与可更新:变更范围扩大时,必须先更新 Touch List 再改代码。
### 目录书写要求(必须遵守)
- Touch List 内每一条必须使用以下两种格式之一:
- **仓库相对路径(推荐)**`src\<module>\<file>`
- **绝对路径(可选)**`<repo-root>\src\<module>\<file>``<repo-root>` 为本机仓库根目录)
- Touch List 禁止包含构建产物与临时目录中的文件(这些文件不应被手工修改,且极易产生冲突),包括但不限于:
- `**\bin\**``**\obj\**`
- `**\node_modules\**`
- `**\.vite\**``**\dist\**`
### Touch List 模板(复制即可用)
- Touch List:
- `src\<module>\<file>`(共享:否|Writer:本窗口)
- `src\<module>\<file>`(共享:是|Writer<窗口名>
- `docs\<file>`(共享:是/否|Writer<窗口名>
- `.trae\<file>`(共享:是|Writer<窗口名>
## 文件所有权与共享文件策略
- **默认规则**:Touch List 中标记为“共享文件”的条目,必须指定唯一 Writer。
- **Writer 约束**
- 非 Writer 窗口不得编辑该共享文件(包括格式化、重排 import、无关重构)。
- 需要对共享文件提出修改时,非 Writer 只能提供“差异建议”(文字说明/伪代码/小片段)交给 Writer 落盘。
- **共享文件判定(满足其一即为共享)**:
- 项目入口/启动逻辑、依赖注入注册、全局路由/导航、公共配置、公共协议与 DTO、公共组件/样式、跨模块公共工具
- 解决方案/项目文件(如 `.sln``.csproj`)、锁文件、全局配置文件(如 `appsettings*`、构建脚本)
## 协调目录(必须遵守)
- 为了让“文件冲突”和“编译中途状态”可操作、可对齐,仓库内必须固定保留一个专用协调目录:
- `.trae\coordination\`
- 该目录只用于协作对齐,不承载业务实现代码;多人可在不同文件中写入,避免互相踩踏。
- 并行推进时必须使用该目录中的文件记录“谁在改什么”和“中途状态怎么保证不破坏编译”:
- `.trae\coordination\ownership.md`:文件/目录所有权(Writer)登记表
- `.trae\coordination\shared-files.md`:本阶段共享文件清单(只有 Integrator 维护)
- `.trae\coordination\handoff\`:非 Writer 提交的差异建议/交接说明(Integrator 落盘)
- `.trae\coordination\wip\`:编译中途状态说明(为什么需要隔离、如何保证绿线、何时收敛)
## 目录分区与低冲突写法
- 优先通过“新增文件”完成并行开发,减少在同一文件内的交错修改。
- 需要扩展既有逻辑时,优先选择低冲突策略:
- C#:新增类/partial 文件、扩展方法、接口实现分文件、平台目录分离
- TypeScript/Vue:新增模块/组件文件,避免在同一大文件内做多处改动
- 禁止在非必要情况下对共享文件做纯格式化、纯重排或无收益重构(这些改动高度易冲突且难以 review)。
## 编译绿线(避免编译区间冲突)
- **不得提交/合入破坏编译的变更**:包括缺失类型、未实现接口、引用不存在、配置缺项导致启动失败等。
- **允许的临时隔离手段(按优先级)**:
1. 新功能先放在新文件/新类中,不在入口路径上启用
2. 通过显式开关控制启用(配置/运行时开关),默认关闭
3. 通过依赖注入分支注册或特性开关隔离,默认不触发
- 当必须进行接口演进时,采用“双写/兼容期”策略:
- 先新增(保持旧接口可用)→ 再迁移调用方 → 最后清理旧接口
## 合入顺序与集成职责
- 每个并行阶段必须明确一个集成窗口(Integrator),负责:
- 处理共享文件的实际落盘与冲突消解
- 保持主干/集成分支持续可编译、可运行
- 其他窗口提交的成果应尽量以“新增文件 + 最小修改点”的方式交付,降低集成成本。
## 最小检查清单
1. [ ] 每个任务 md 是否已写 Touch List(精确到文件)?
2. [ ] Touch List 中的共享文件是否指定了唯一 Writer?
3. [ ] 是否避免了对共享文件的无意义格式化/重排?
4. [ ] 当前改动是否保持可编译(绿线)?
5. [ ] 若涉及接口演进,是否采用兼容期策略而非一次性破坏式变更?
+50
View File
@@ -0,0 +1,50 @@
---
alwaysApply: false
---
# 任务拆分输出规范(必须遵守)
## 适用时机
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出任务拆分文档,再开始写代码或改配置。
## 输出要求
- **先读完所有相关文档**:包括但不限于 `docs/``docs/project/` 下与本次需求相关的内容。
- **先写任务拆分,再动手实现**:任务拆分产出是后续执行的入口与对齐依据。
- **新增一个专属文件夹**:在 `d:\Proj\6.Hua.Todo\docs\project` 下新建一个文件夹存放本次任务拆分文档。
- 文件夹命名建议:`任务拆分-<主题>-<日期或版本>`(保持可检索、避免与既有文档冲突)。
- **可并行的任务要拆成不同 md**:能同步执行(相互无依赖/弱依赖)的任务,必须拆到不同的 Markdown 文件中,便于并行推进与分工。
- **文件必须带序号**:同一文件夹下的 md 文件按执行顺序编号,序号从小到大。
- 文件名建议:`01-xxx.md``02-xxx.md``03-xxx.md`
- **每个任务文件至少包含**
- 目标/范围(做什么、不做什么)
- 前置条件(依赖哪些结论/接口/文档)
- 验收标准(怎么判断完成,包含可执行的验证点)
- 风险与回滚(如有)
- **子任务完成后的标记要求**
- 当任一子任务(例如 `01-*`/`02-*`/`03-*`)完成实现后,必须在对应版本的 `00-任务总览.md` 中同步标注“已完成”。
- 同时必须维护一张“待验证表”(可用 Markdown 表格),对每个子任务给出“待验证/已验证”状态,避免实现完成但验收未闭环。
- **并行冲突规避要求**:当任务会被分发到多个 solo 窗口并行推进时,每个任务文件必须额外包含:
- 触碰文件清单(Touch List,精确到文件)
- 共享文件策略(哪些是共享文件、唯一 Writer 是谁、如何与集成窗口对接)
- 编译绿线策略(如何确保阶段性交付不破坏编译)
## 推荐结构(模板)
- `00-总览.md`
- 背景与目标
- 关键决策与约束
- 并行分组说明(哪些文件可同步做)
- `01-<并行任务A>.md`
- `02-<并行任务B>.md`
- `03-<串行任务C>.md`
## 最小检查清单
1. [ ] 是否确认已阅读完所有相关文档?
2. [ ] 是否在 `docs/project` 下新建了本次专属文件夹?
3. [ ] 是否产出 `00-总览.md`(或等价总览文件)?
4. [ ] 是否将可并行任务拆分为不同 md 文件?
5. [ ] 是否所有 md 文件都带有连续序号?
6. [ ] 并行任务是否为每个任务文件补充了 Touch List/共享文件策略/编译绿线策略?
7. [ ] 子任务完成后,是否在对应版本的 `00-任务总览.md` 标注“已完成”,并在“待验证表”里更新状态?
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True)">true</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
</PropertyGroup>
</Project>
+2 -3
View File
@@ -6,9 +6,8 @@
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj">
<Build Solution="Debug|*" Project="true" />
</Project>
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj" />
<Project Path="src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj" />
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
+18 -2
View File
@@ -1,16 +1,17 @@
# Hua.Todo 跨平台代办管理应用
一个基于 MAUI + WebView 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示)
## 📦 安装与使用
@@ -46,6 +47,19 @@ npm run dev
```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
### Windows 交付产物(安装包)
- 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件)
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json``WebServer.IsUsingStatic=true`
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件)
### Linux 交付产物(v1.2.0
- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux`
- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/`
### 使用说明
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
@@ -57,6 +71,7 @@ npm run dev
### 项目结构
```
Hua.Todo/
├── pack/ # 打包与交付产物(Linux/安装包等)
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ └── project/ # 项目进度/需求文档
@@ -66,6 +81,7 @@ Hua.Todo/
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux)
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档
+4 -1
View File
@@ -5,7 +5,7 @@
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **UI 框架**MAUI (Multi-platform App UI)
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
@@ -37,3 +37,6 @@
### Hua.Todo.Maui
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
### Hua.Todo.Avalonia
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
+9 -1
View File
@@ -9,6 +9,14 @@
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### v1.2.0(开发中,2026-04-07
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示)。
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`
- **Windows 发布打包修复**Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
### v1.1.1 (2026-04-06)
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
@@ -29,8 +37,8 @@
### v1.2.0 规划内容 (即将推出)
- **Linux 官方支持**:正式适配 Linux 平台。
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpakmanifest/desktop entry/AppStream)基础结构。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
@@ -0,0 +1,49 @@
# Hua.Todo v1.2.0 任务拆分总览
本目录用于把 [产品需求文档-1.2.0.md](file:///d:/Proj/6.Hua.Todo/docs/project/产品需求文档-1.2.0.md) 拆解为可落地、可并行推进的子任务。各任务文件之间尽量解耦;存在明确依赖时会在任务内标注。
## 目标(来自 PRD
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有“本地 API ↔ 前端”的交互协议
- 任务检索(Search):主界面顶部新增搜索框,按任务标题模糊匹配
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的“可控落盘”
## 当前实现基线(用于任务定位)
- MAUI 启动与内嵌 WebServer`src/Hua.Todo.Maui`
- 前端(Vue)与 API client`src/Hua.Todo.Web`
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__``window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
## 任务流(并行建议)
- **Linux 入口线**`01-*` + `02-*`
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1``pack/linux/`
- **Search 线**`03-*`
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
- 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
- **云同步线**`04-*` + `05-*` + `06-*`
- 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离)
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务)
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与“内存模式”)
- **文档与验收线**`07-*`
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
## 待验证表
| 子任务 | 实现状态 | 验证状态 | 备注 |
|---|---|---|---|
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增“云同步设置”弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 |
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
| 07 - 文档与验收 | 未标注 | 待验证 | |
## 交付判定(v1.2.0 Done Definition
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
- Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发“是否允许落盘”;客户端在禁止落盘时不产生本地持久化
@@ -0,0 +1,93 @@
# 01 - LinuxAvalonia 入口 + WebView 承载 Vue
## 目标
- 新增一个 **Linux 桌面端入口**Avalonia)作为 Hua.Todo 的 Linux 宿主
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
- 复用既有“前端 ↔ 本地 API”的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
## 范围
- 新增/调整项目结构以容纳 Avalonia 入口(建议新项目:`src/Hua.Todo.Avalonia``src/Hua.Todo.Desktop.Avalonia`
- 在 Avalonia 中接入可在 Linux 运行的 WebView 控件(需要先调研与选型)
- 确保能正确加载:
- 内嵌 WebServer 的 `BaseUrl`
- 以及前端静态资源(开发态/生产态策略需明确)
## 依赖
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`
## 关键决策点(必须在实现前落定)
### 1) Avalonia/Linux 可用的 WebView 方案选型
候选方向(示例,最终以调研结果为准):
- WebKitGTK 系(Linux 依赖较重,但适配面广)
- Chromium 系(体积/依赖/许可成本需评估)
选型必须满足的最低能力:
- 基础导航与本地资源加载
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证“HTTP API”路径通)
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
### 2) 资源加载策略(与 MAUI 对齐)
需要明确并实现两种模式:
- 开发态:Avalonia WebView 指向前端 dev server(例如 `http://localhost:5173`)或指向本地 WebServer
- 生产态:加载随应用交付的静态资源(`wwwroot`)并确保 SPA fallback 生效(访问非 `/assets/*` 的路由能回到 `index.html`
## 实现落地(已完成)
### 1) 项目结构
- 新增 Linux 桌面端入口项目:`src/Hua.Todo.Avalonia`
- 入口类型:Avalonia Desktop AppClassicDesktopLifetime
### 2) WebView 方案选型(已落定)
- 采用:`WebView.Avalonia` + `WebView.Avalonia.Desktop`
- Windows:依赖 WebView2 Runtime
- Linux:依赖 GTK + WebKitGTK(运行环境缺失时 WebView 会初始化失败)
### 3) 资源加载策略(与 MAUI 对齐)
- 生产态(默认):内嵌 WebServer 托管 `wwwroot` 静态资源,WebView 指向 `HostUrl`
- 开发态:将 `IsUsingStatic=false`WebView 指向 `ForEndUrl`(例如 Vite dev server
### 4) 前端契约对齐(与 MAUI 一致)
- 在 WebView 导航完成后注入:
- `window.__API_BASE_URL__ = "${HostUrl}/api"`
- `window.mauiInterop`(事件名/字段名与现有前端保持一致)
### 5) 本地 API 与数据库初始化
- 内嵌 WebServer 使用 Kestrel 启动并注册动态 API 与 Controllers
- 启动时执行数据库迁移(Migrate),并设置 SQLite WAL 模式以降低锁冲突风险
- 默认 SQLite 路径使用用户目录(`LocalApplicationData/Hua.Todo/Hua.Todo.db`),避免安装目录无写权限导致启动失败
## 实施步骤(建议)
1. 新建 Avalonia 宿主项目
- 提供 App/Window/MainView 基础结构
2. 接入 WebView 控件并完成最小加载闭环
- 能显示 `index.html`,能打开前端路由
3. 对齐与 MAUI 端一致的“前端契约”
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
4. 复用本地 API(内嵌 WebServer
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
- 明确 Linux 端是否也走“内嵌 WebServer + WebView 指向 BaseUrl”的方式(推荐一致化,减少前端差异)
5. 输入法/字体/DPI 验证与可配置化
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
## 验收标准
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
- WebView 正常渲染前端主界面
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
- 页面路由/刷新不会出现 404SPA fallback 生效)
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
@@ -0,0 +1,41 @@
# 02 - Linux:打包、依赖与交付产物
## 目标
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到“用户机器无需手动补大量依赖即可运行”
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
## 范围
- 构建脚本与产物输出
- 运行时依赖打包策略(尤其是 WebView 相关)
- 基础安装/运行说明(README / docs 更新)
## 依赖
- 依赖 `01-*`Avalonia 入口与 WebView 方案已定、并能在开发机运行)
## 交付形式(PRD 约束)
- 优先提供一种“自包含”官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
- 同时保留 `.deb``.tar.gz` 作为补充分发形式(以脚本产出为准)
## 实施步骤(建议)
1. 确定 Linux 发行版基线与运行时依赖清单
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
2. 选择并落地一种自包含交付形式
- AppImage:偏“拎包即用”,对依赖捆绑要求高
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
3. 补充 `.deb``.tar.gz`
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
- `.tar.gz`:最通用;需要启动脚本与依赖说明
4. 增加启动前自检(可选但推荐)
- 发现关键依赖缺失时给出清晰错误提示与修复建议
## 验收标准
- 产物可在“干净环境”(尽量接近用户机)安装/运行
- 启动后 WebView 能渲染前端,且本地 API 可用
- 文档中给出的安装/运行步骤可复现,且与产物一致
@@ -0,0 +1,45 @@
# 03 - Search:任务标题关键词检索
## 目标
- 在主界面顶部增加搜索框
- 支持按任务标题进行模糊匹配(实时过滤)
## 范围
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
- 过滤逻辑:对“树状任务”给出明确策略
- 性能:任务量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
## 依赖
- 不依赖 Linux/Avalonia 或云同步,可独立并行完成
## 过滤策略(需在实现时确定,并写入实现说明)
树状任务(父子任务)常见两种策略,任选其一并保持一致:
1. **命中即显示(含上下文)**
- 任意节点标题命中则显示该节点
- 若子任务命中,可同时显示其祖先链(便于理解层级)
2. **扁平化命中**
- 只显示命中的任务(可能丢失层级语义)
建议优先采用“命中即显示(含上下文)”,用户可更快定位。
## 实施步骤(建议)
1. 确定搜索框放置位置(主界面顶部)
2. 增加 `searchQuery` 状态与派生 `filteredTasks`
3. 将过滤逻辑接入现有列表渲染
4. 补充交互细节
- 输入时实时过滤
- 清空按钮
- Esc 清空、保持输入焦点(可选)
## 验收标准
- 输入关键字后,任务列表实时过滤,仅展示命中结果(符合上述策略)
- 清空后恢复原列表
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
@@ -0,0 +1,145 @@
# 04 - 云同步(基础可用):服务端基础能力
## 目标(PRD 约束)
- 支持用户级任务数据同步/读取(用户隔离)
- 提供基于角色/权限的访问控制(RBAC)
- 对高风险操作支持二次认证(能力以服务端实现为准)
- 下发“可控落盘”配置(服务端配置驱动)
## 范围(建议最小闭环)
- 认证(登录/会话)
- 任务数据 API(按用户隔离)
- 配置下发 API(是否允许落盘、终端可信度/会话策略等)
- RBAC 最小落地(至少能区分“允许同步/禁止同步”或“只读/读写”)
- 二次认证最小落地(至少覆盖“启用/关闭同步、切换账号、调整落盘策略”等高风险操作的接口)
## 依赖
-`05-*`(客户端)可并行推进,但需要尽早冻结 API 契约(字段名/响应结构/错误码)
## 接口契约(需要在实现前先写清楚)
v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结字段名/错误码)。
### 通用约定
- Base URL:由客户端配置(例如 `https://{host}:{port}`
- 认证:`Authorization: Bearer {accessToken}`
- 错误响应(统一结构):
```json
{ "code": "ERROR_CODE", "message": "Human readable message." }
```
### 错误码
- `UNAUTHORIZED`:未登录或会话失效
- `FORBIDDEN`:权限不足(RBAC / 策略拒绝)
- `SECOND_FACTOR_REQUIRED`:需要二次认证(step-up
- `BAD_REQUEST`:请求参数不合法
- `NOT_FOUND`:资源不存在
### Auth
- 初始化管理员(仅当系统尚无云用户时允许一次):
- `POST /auth/bootstrap`
- Body`{ "userName": "admin", "password": "..." }`
- 200:成功;403:已初始化或不允许
- 登录:
- `POST /auth/login`
- Body`{ "userName": "admin", "password": "..." }`
- 200`{ accessToken, expiresAtUtc, userId, role, permissions[] }`
- 二次认证(step-up,v1.2.0 最小实现为“复用登录口令进行再认证”):
- `POST /auth/step-up`
- Header`Authorization: Bearer ...`
- Body`{ "password": "..." }`
- 200`{ stepUpExpiresAtUtc }`
### Tasks
- 获取当前用户任务全量:
- `GET /tasks`
- Header`Authorization: Bearer ...`
- 权限:`tasks:read`
- 200`CloudTaskItem[]`
### Sync
- 上传/同步(增删改后返回最新全量):
- `POST /sync`
- Header`Authorization: Bearer ...`
- 权限:`sync:write`
- 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`
- Body
```json
{
"upserts": [
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null },
{ "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null }
],
"deletes": [2, 3]
}
```
- 200
```json
{
"serverTimeUtc": "2026-04-06T17:39:30.0281279Z",
"tasks": [ /* CloudTaskItem[] */ ]
}
```
### Security Policy
- 获取安全策略(服务端下发):
- `GET /security/policy`
- Header`Authorization: Bearer ...`
- 权限:`policy:read`
- 200
```json
{
"allowPersist": true,
"allowSync": true,
"requireSecondFactorFor": ["sync:write", "policy:write"]
}
```
- 更新安全策略(高风险操作):
- `PUT /security/policy`
- Header`Authorization: Bearer ...`
- 权限:`policy:write`
- 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`
- Body`{ "allowPersist": false, "allowSync": false }`
## 关键实现点(建议)
1. 用户隔离
- 服务端所有读写必须绑定当前登录用户上下文
2. 权限模型(RBAC
- 定义最小角色:例如 `user``admin`
- 定义最小权限:例如 `tasks:read``tasks:write``sync:enable`
3. 二次认证
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现“二次口令”)
- 能力不足时必须返回明确错误,使客户端可提示用户
4. 落盘策略下发
- 支持按用户或按会话/终端下发
- 默认策略建议为“允许落盘”,便于可用性;但需支持“禁止落盘”
## 验收标准
- 使用不同用户登录后获取任务,数据严格隔离
- 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别)
- 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为
## 当前实现说明(v1.2.0
- 数据隔离:`Tasks` 表新增 `UserId` 外键,云端 API 的所有读写按当前会话用户隔离;本地模式使用固定的 `local` 用户 ID(不影响既有 Dynamic API)。
- RBAC:内置 `admin / user / readonly / nosync` 角色与权限映射;并叠加 `SecurityPolicies.AllowSync` 作为“是否允许同步写入”的策略开关。
- 二次认证:通过 `POST /auth/step-up` 将会话提升到 step-up 状态;`POST /sync``PUT /security/policy` 会强制要求 step-up。
@@ -0,0 +1,48 @@
# 05 - 云同步(基础可用):客户端配置与同步工作流
## 目标(PRD 约束)
- 首次启用同步时,用户需要**手动填写服务端地址**(例如 `https://example.com`),并允许后续在设置中修改
- 客户端对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息
- 用户登录成功后,从服务端获取该用户任务并更新到前端展示
## 范围(建议最小闭环)
- “同步设置”入口与界面(服务端地址、登录、同步开关)
- 地址校验与风险提示
- 登录后拉取任务并刷新 UI
- 与本地数据的关系(v1.2.0 建议先做到“服务端为准/或本地为准”的单一策略,避免引入复杂冲突解决)
## 依赖
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取任务 + 获取安全策略)
-`03-*`Search)互不影响,可并行
## 关键交互与状态
需要定义并贯穿实现的状态机(至少包含):
- 未配置服务端地址
- 已配置未登录
- 已登录(可同步)
- 同步中/同步失败/同步成功(含失败原因)
## 实施步骤(建议)
1. 增加“同步设置”UI
- 放置在主界面可发现位置(例如顶部右侧设置按钮/侧边栏)
2. 服务端地址配置
- 基础校验:`https?://`、host 合法性、尾部 `/` 处理规则
- 保存时探测:尝试请求服务端健康检查或登录端点(并提示证书异常/不可达等)
3. 登录与凭据存储策略(与 `06-*` 协同)
- 在允许落盘场景下可持久化 token/会话
- 在禁止落盘场景下仅保留内存会话(退出即失效)
4. 登录后拉取任务
- 拉取成功后更新前端任务列表
- 拉取失败给出可理解提示,并允许重试
## 验收标准
- 首次启用同步必须先配置服务端地址;地址非法时不可保存并提示原因
- 保存地址时能提示“不可达/证书异常”等风险信息(至少能区分:成功、失败、存在风险但可继续)
- 登录后能从服务端拉取任务并展示在主界面
@@ -0,0 +1,56 @@
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与“可控落盘”
## 目标(PRD 约束)
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整“是否允许落盘”等)
- 客户端读取服务端安全配置,决定任务信息是否允许落盘
- 当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据
## 范围
- 客户端:落盘策略切换(持久化 ↔ 内存)、高风险操作二次确认/二次认证 UI
- 服务端:策略下发 + 二次认证挑战/校验接口(与 `04-*` 协作)
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
### 1) “落盘”定义与边界
需要明确“哪些数据算落盘”,并在禁止落盘时全部避免:
- 任务数据(列表/详情)
- 同步状态(上次同步时间、待同步队列等)
- 登录凭据(token/refresh token/会话标识)
### 2) 本地存储抽象
建议把前端(或宿主)本地存储封装为可替换实现:
- 允许落盘:使用现有 localStorage/SQLite 等
- 禁止落盘:使用内存实现(刷新/退出即清空)
要求:
- 切换策略时行为可预期(例如从允许 → 禁止:立即清空已落盘数据并提示用户)
- 不在日志或 UI 中暴露敏感信息
### 3) 二次认证(挑战-响应)
建议采用“服务端驱动”的挑战流程:
- 客户端发起高风险操作
- 服务端返回“需要二次认证”的响应(包含 challenge 信息)
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
- 客户端携带证明再次提交
若服务端暂不支持二次认证,也需有“能力探测/降级提示”,避免客户端卡死。
## 验收标准
- 服务端返回 `allowPersist=false` 时:
- 客户端不会把任务数据与凭据写入任何持久化介质
- 退出应用后重新进入,任务数据为空(需重新登录/拉取)
- 对指定高风险操作:
- 无二次认证证明时被拒绝,并提示需要二次认证
- 提供正确二次认证后操作成功
@@ -0,0 +1,39 @@
# 07 - 文档同步与验收清单(v1.2.0)
## 目标
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
- 为 Linux / Search / 云同步提供可复现的验收步骤
## 范围
- READMEFeatures、运行方式、API 说明(如涉及)
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
## 必做同步点(结合当前仓库现状)
- **API 端点与项目结构**:对齐实际实现(避免文档仍描述不存在的项目或旧端点)
- **Linux 入口说明**:新增 Avalonia 入口的构建/运行/打包方式
- **云同步说明**:服务端地址配置、登录、落盘策略与安全机制(RBAC/二次认证)的用户可理解描述
- **版本记录**:在 `docs/版本记录.md` 增加 v1.2.0 非琐碎变更条目
## 验收清单(建议逐项勾选)
### Linux
- [ ] Linux 上可启动并打开主界面
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
### Search
- [ ] 主界面有搜索框
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [ ] 清空后恢复原列表
### 云同步(基础可用)
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
- [ ] 登录后能拉取该用户任务并展示
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [ ] 高风险操作触发二次认证(服务端支持时)
+115
View File
@@ -0,0 +1,115 @@
<#
Hua.Todo Linux 发布脚本(产出 .tar.gz
目标:
- 为 Linux 提供可分发的目录产物(dotnet publish 输出)
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链
#>
param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[switch]$SelfContained,
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
function Get-FirstExistingFilePath {
param(
[Parameter(Mandatory = $true)]
[string[]]$Candidates
)
foreach ($candidate in $Candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
return $null
}
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
)
$currentVersion = "0.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
return $currentVersion
}
$candidateProjects = @(
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj")
)
$ProjectFile = Get-FirstExistingFilePath -Candidates $candidateProjects
if ($null -eq $ProjectFile) {
$candidatesText = ($candidateProjects | ForEach-Object { " - $_" }) -join "`n"
Write-Error @"
Avalonia Linux Linux
docs/project/v1.2.0-tasks/01-Linux-AvaloniaWebView.md
$candidatesText
"@
exit 1
}
$version = Read-ProjectVersion -ProjectFile $ProjectFile
$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier"
$publishDir = Join-Path $artifactRoot "publish"
if (Test-Path $artifactRoot) {
Remove-Item -Recurse -Force $artifactRoot
}
New-Item -ItemType Directory -Path $publishDir | Out-Null
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan
dotnet publish $ProjectFile `
-c $Configuration `
-r $RuntimeIdentifier `
--self-contained $selfContainedValue `
-o $publishDir
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed"
exit 1
}
$packageName = "hua.todo-$version-$RuntimeIdentifier.tar.gz"
$packagePath = Join-Path $artifactRoot $packageName
Write-Host "Creating tar.gz: $packageName" -ForegroundColor Cyan
tar -C $publishDir -czf $packagePath .
if ($LASTEXITCODE -ne 0) {
Write-Error "tar.gz packaging failed"
exit 1
}
Write-Host "Linux artifact created: $packagePath" -ForegroundColor Green
+325
View File
@@ -0,0 +1,325 @@
param(
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
[string]$RuntimeIdentifier = "win-x64",
[string]$Configuration = "Release",
[switch]$SkipInnoSetup,
[switch]$SkipVersionBump,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier))
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
)
$currentVersion = "0.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
return $currentVersion
}
function Read-InnoSetupAppName {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript
)
if (!(Test-Path $SetupScript)) {
return $null
}
$content = Get-Content $SetupScript -Raw
$match = [regex]::Match($content, '#define\s+MyAppName\s+"([^"]+)"')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
return $null
}
# Read OutputBaseFilename from setup.iss so we can determine the exact installer file path after ISCC runs.
# The value may contain Inno preprocessor macros like {#MyAppName}/{#MyAppVersion}.
function Read-InnoSetupOutputBaseFilename {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript
)
if (!(Test-Path $SetupScript)) {
return $null
}
$content = Get-Content $SetupScript -Raw
$match = [regex]::Match($content, '^\s*OutputBaseFilename\s*=\s*(.+?)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline)
if ($match.Success) {
return $match.Groups[1].Value.Trim().Trim('"')
}
return $null
}
# Resolve the subset of Inno preprocessor macros used by this repo to a concrete file base name.
function Resolve-InnoSetupOutputBaseFilename {
param(
[Parameter(Mandatory = $true)]
[string]$Template,
[string]$AppName,
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Template)) {
return $null
}
$resolved = $Template
if (![string]::IsNullOrWhiteSpace($AppName)) {
$resolved = $resolved.Replace("{#MyAppName}", $AppName)
}
if (![string]::IsNullOrWhiteSpace($Version)) {
$resolved = $resolved.Replace("{#MyAppVersion}", $Version)
}
return $resolved.Trim()
}
function Update-InnoSetupVersion {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript,
[Parameter(Mandatory = $true)]
[string]$Version
)
if (!(Test-Path $SetupScript)) {
return
}
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $Version + '"'
$versionFound = $true
break
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent -Encoding UTF8
}
}
function Copy-Directory {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (Test-Path $Destination) {
Remove-Item -Recurse -Force $Destination
}
New-Item -ItemType Directory -Path $Destination | Out-Null
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile
Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion
Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
exit 1
}
$publishDir = Join-Path $ProjectDir ("bin\{0}\{1}\{2}\publish" -f $Configuration, $TargetFramework, $RuntimeIdentifier)
if (!(Test-Path $publishDir)) {
$publishDir = (Get-ChildItem -Path (Join-Path $ProjectDir "bin\$Configuration") -Recurse -Directory -Filter publish -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName)
}
if (!(Test-Path $publishDir)) {
Write-Error "Publish directory not found"
exit 1
}
$publishAppSettings = Join-Path $publishDir "appsettings.json"
if (Test-Path $publishAppSettings) {
$appSettingsObj = Get-Content $publishAppSettings -Raw | ConvertFrom-Json
if ($null -eq $appSettingsObj.WebServer) {
$appSettingsObj | Add-Member -MemberType NoteProperty -Name "WebServer" -Value ([pscustomobject]@{})
}
$appSettingsObj.WebServer.IsUsingStatic = $true
$appSettingsObj | ConvertTo-Json -Depth 32 | Set-Content -Path $publishAppSettings -Encoding UTF8
} else {
Write-Error "Publish appsettings.json not found: $publishAppSettings"
exit 1
}
$desiredExeName = "$ProjectBaseName.exe"
$desiredExePath = Join-Path $publishDir $desiredExeName
if (!(Test-Path $desiredExePath)) {
$candidateExe = $null
$preferredCandidateNames = @(
"Hua.Todo.Maui.exe",
"Hua.Todo.exe"
)
foreach ($name in $preferredCandidateNames) {
$candidatePath = Join-Path $publishDir $name
if (Test-Path $candidatePath) {
$candidateExe = Get-Item -Path $candidatePath
break
}
}
if ($null -eq $candidateExe) {
$exeFiles = Get-ChildItem -Path $publishDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike "*Setup*" }
$candidateExe = $exeFiles | Where-Object { Test-Path (Join-Path $publishDir ($_.BaseName + ".dll")) } | Select-Object -First 1
if ($null -eq $candidateExe) {
$candidateExe = $exeFiles | Sort-Object Length -Descending | Select-Object -First 1
}
}
if ($null -ne $candidateExe) {
Rename-Item -Path $candidateExe.FullName -NewName $desiredExeName -Force
}
}
if (!(Test-Path $desiredExePath)) {
Write-Error "Publish main executable not found: $desiredExeName"
exit 1
}
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
exit 1
}
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
Remove-Item -Recurse -Force $publishWwwroot
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
$installerPath = $null
if (!$SkipInnoSetup.IsPresent) {
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
$innoAppName = Read-InnoSetupAppName -SetupScript $SetupScript
$outputDir = Join-Path $ProjectDir "Output"
$outputBaseTemplate = Read-InnoSetupOutputBaseFilename -SetupScript $SetupScript
$resolvedOutputBase = Resolve-InnoSetupOutputBaseFilename -Template $outputBaseTemplate -AppName $innoAppName -Version $currentVersion
$expectedInstallerPath = $null
if (![string]::IsNullOrWhiteSpace($resolvedOutputBase)) {
$expectedInstallerPath = Join-Path $outputDir ("{0}.exe" -f $resolvedOutputBase)
}
if (![string]::IsNullOrWhiteSpace($innoAppName)) {
# Prevent stale unversioned installer (Output\Hua.Todo.exe) from confusing local release artifacts.
$oldInstallerPath = Join-Path $outputDir ("{0}.exe" -f $innoAppName)
if (Test-Path $oldInstallerPath) {
Remove-Item -Path $oldInstallerPath -Force
}
}
# ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly).
$maxAttempts = 3
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
break
}
Write-Host $isccOutput
if ($attempt -lt $maxAttempts -and ($isccOutput -match 'Compile aborted|being used by another process|Sharing violation|process cannot access')) {
Start-Sleep -Seconds 2
continue
}
Write-Error "Packaging failed"
exit 1
}
if ($null -ne $expectedInstallerPath -and (Test-Path $expectedInstallerPath)) {
$installerPath = $expectedInstallerPath
}
if ($null -eq $installerPath) {
if (Test-Path $outputDir) {
$installerPath = (Get-ChildItem -Path $outputDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName)
}
}
Write-Host "Setup package created successfully!" -ForegroundColor Green
if ($null -ne $installerPath -and (Test-Path $installerPath)) {
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
}
} else {
Write-Error "Inno Setup compiler not found"
exit 1
}
}
if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
$publishArtifactDir = Join-Path $ArtifactsRoot "publish"
$installerArtifactDir = Join-Path $ArtifactsRoot "installer"
Copy-Directory -Source $publishDir -Destination $publishArtifactDir
if ($null -ne $installerPath -and (Test-Path $installerPath)) {
if (Test-Path $installerArtifactDir) {
Remove-Item -Recurse -Force $installerArtifactDir
}
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
$installerName = "hua.todo-$currentVersion-$RuntimeIdentifier-setup.exe"
Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force
}
}
if (!$SkipVersionBump.IsPresent) {
$versionMatch = [regex]::Match($currentVersion, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$')
if ($versionMatch.Success) {
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
$content = Get-Content $ProjectFile -Raw
if ($content -match "<Version>[^<]*</Version>") {
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content -Encoding UTF8
} else {
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
}
} else {
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
}
}
+53 -52
View File
@@ -1,62 +1,63 @@
param(
[switch]$Windows,
[switch]$Linux,
[string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"),
[switch]$LinuxSelfContained,
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
[switch]$SkipWindowsVersionBump
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
# Read version from project file
$currentVersion = "1.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
} else {
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
$shouldPublishWindows = $Windows.IsPresent
$shouldPublishLinux = $Linux.IsPresent
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishWindows = $true
$shouldPublishLinux = $true
}
if ($shouldPublishWindows) {
if (!(Test-Path $publishWindowsScript)) {
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
exit 1
}
& $publishWindowsScript `
-Configuration $Configuration `
-SkipInnoSetup:$SkipWindowsInnoSetup `
-SkipVersionBump:$SkipWindowsVersionBump
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
# Update setup script version with current version before build
if (Test-Path $SetupScript) {
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"'
$versionFound = $true
break
if ($shouldPublishLinux) {
if (!(Test-Path $publishLinuxScript)) {
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
exit 1
}
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript `
-RuntimeIdentifier $rid `
-SelfContained:$LinuxSelfContained `
-Configuration $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent
}
}
Write-Host "Building Hua.Todo.Maui (Release)..." -ForegroundColor Cyan
dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
exit 1
}
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
& $ISCC $SetupScript
if ($LASTEXITCODE -eq 0) {
Write-Host "Setup package created successfully!" -ForegroundColor Green
} else {
Write-Error "Packaging failed"
}
} else {
Write-Error "Inno Setup compiler not found"
}
$versionParts = $currentVersion.Split(".")
$patch = [int]$versionParts[2] + 1
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
$content = Get-Content $ProjectFile -Raw
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content
@@ -0,0 +1,53 @@
using System.Security.Claims;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// 获取当前用户 ID(若未登录则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>用户 ID。</returns>
public static Guid? GetUserId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 获取当前会话 ID(若不可用则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>会话 ID。</returns>
public static Guid? GetSessionId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.Sid);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 判断是否具备指定权限。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <param name="permission">权限点。</param>
/// <returns>是否具备权限。</returns>
public static bool HasPermission(this ClaimsPrincipal user, string permission)
{
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 判断是否已完成二次认证(step-up)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>是否已完成二次认证。</returns>
public static bool HasStepUp(this ClaimsPrincipal user)
{
return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权使用的 Claim 名称约定。
/// </summary>
public static class CloudClaims
{
/// <summary>
/// 权限 Claim(可重复出现)。
/// </summary>
public const string Permission = "perm";
/// <summary>
/// 二次认证状态 Claim。
/// </summary>
public const string StepUp = "step_up";
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步权限点常量。
/// </summary>
public static class CloudPermissions
{
/// <summary>
/// 读取任务数据。
/// </summary>
public const string TasksRead = "tasks:read";
/// <summary>
/// 写入任务数据。
/// </summary>
public const string TasksWrite = "tasks:write";
/// <summary>
/// 允许执行同步写入(用于区分“允许同步/禁止同步”)。
/// </summary>
public const string SyncWrite = "sync:write";
/// <summary>
/// 读取安全策略。
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// 修改安全策略(高风险)。
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// 管理用户(创建/修改角色)。
/// </summary>
public const string UsersManage = "users:manage";
}
@@ -0,0 +1,43 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 默认的角色-权限映射(内置最小集合)。
/// </summary>
public class DefaultRolePermissionMapper : IRolePermissionMapper
{
/// <inheritdoc />
public IReadOnlyList<string> GetPermissions(string role)
{
return role switch
{
"admin" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead,
CloudPermissions.PolicyWrite,
CloudPermissions.UsersManage
},
"readonly" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.PolicyRead
},
"nosync" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.PolicyRead
},
_ => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead
}
};
}
}
@@ -0,0 +1,15 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 角色到权限集合的映射器(RBAC 最小落地)。
/// </summary>
public interface IRolePermissionMapper
{
/// <summary>
/// 获取指定角色对应的权限集合。
/// </summary>
/// <param name="role">角色名称。</param>
/// <returns>权限列表。</returns>
IReadOnlyList<string> GetPermissions(string role);
}
@@ -0,0 +1,13 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步会话鉴权默认配置。
/// </summary>
public static class SessionAuthenticationDefaults
{
/// <summary>
/// 会话鉴权 Scheme 名称。
/// </summary>
public const string Scheme = "CloudSession";
}
@@ -0,0 +1,114 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Hua.Todo.Application.Data;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 基于服务端会话表的 Bearer Token 鉴权处理器。
/// </summary>
public class SessionAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly TodoDbContext _dbContext;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="SessionAuthenticationHandler"/>。
/// </summary>
/// <param name="options">鉴权 scheme 选项。</param>
/// <param name="logger">日志。</param>
/// <param name="encoder">编码器。</param>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
TodoDbContext dbContext,
IRolePermissionMapper rolePermissionMapper)
: base(options, logger, encoder)
{
_dbContext = dbContext;
_rolePermissionMapper = rolePermissionMapper;
}
/// <inheritdoc />
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
return AuthenticateResult.NoResult();
}
if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var token = authorization["Bearer ".Length..].Trim();
if (!Guid.TryParse(token, out var sessionId))
{
return AuthenticateResult.Fail("Invalid bearer token format.");
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions
.AsNoTracking()
.Include(s => s.User)
.FirstOrDefaultAsync(s => s.Id == sessionId, Context.RequestAborted);
if (session == null || session.User == null)
{
return AuthenticateResult.Fail("Session not found.");
}
if (session.ExpiresAtUtc <= now)
{
return AuthenticateResult.Fail("Session expired.");
}
var user = session.User;
var permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList();
var allowSync = await _dbContext.SecurityPolicies
.AsNoTracking()
.Where(p => p.UserId == user.Id)
.Select(p => (bool?)p.AllowSync)
.FirstOrDefaultAsync(Context.RequestAborted);
if (allowSync.HasValue && !allowSync.Value)
{
permissions.RemoveAll(p => string.Equals(p, CloudPermissions.SyncWrite, StringComparison.OrdinalIgnoreCase));
}
var claims = new List<Claim>
{
new(ClaimTypes.Sid, session.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Role, user.Role)
};
foreach (var p in permissions)
{
claims.Add(new Claim(CloudClaims.Permission, p));
}
if (session.StepUpExpiresAtUtc.HasValue && session.StepUpExpiresAtUtc.Value > now)
{
claims.Add(new Claim(CloudClaims.StepUp, "true"));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端 API 路由注册扩展。
/// </summary>
public static class CloudSyncEndpointExtensions
{
/// <summary>
/// 映射云同步相关 API 端点。
/// </summary>
/// <param name="app">Web 应用。</param>
/// <returns>Web 应用。</returns>
public static WebApplication MapCloudSyncEndpoints(this WebApplication app)
{
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).AllowAnonymous();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).AllowAnonymous();
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
return app;
}
private static async Task<IResult> BootstrapAdminAsync(
BootstrapAdminRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken);
if (!ok)
{
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
}
return Results.Ok();
}
private static async Task<IResult> LoginAsync(
LoginRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken);
if (response == null)
{
return CloudApiErrors.Unauthorized("Invalid credentials.");
}
return Results.Json(response);
}
private static async Task<IResult> StepUpAsync(
StepUpRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("Password is required.");
}
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken);
if (!expiresAt.HasValue)
{
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
}
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
}
private static async Task<IResult> GetTasksAsync(
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.TasksRead))
{
return CloudApiErrors.Forbidden();
}
var tasks = await taskService.GetTasksAsync(userId.Value, cancellationToken);
return Results.Json(tasks);
}
private static async Task<IResult> SyncAsync(
SyncRequest request,
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.SyncWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
return Results.Json(response);
}
private static async Task<IResult> GetPolicyAsync(
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyRead))
{
return CloudApiErrors.Forbidden();
}
var policy = await policyService.GetPolicyAsync(userId.Value, cancellationToken);
return Results.Json(policy);
}
private static async Task<IResult> UpdatePolicyAsync(
UpdateSecurityPolicyRequest request,
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken);
return Results.Json(policy);
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端能力的依赖注入扩展。
/// </summary>
public static class CloudSyncServiceCollectionExtensions
{
/// <summary>
/// 注册云同步相关的认证、授权与业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncServer(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<IRolePermissionMapper, DefaultRolePermissionMapper>();
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
services.AddAuthorization(options =>
{
options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead));
options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite));
options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite));
options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead));
options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite));
options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage));
});
return services;
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 统一错误响应结构。
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// 错误码(用于客户端识别与提示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 错误消息(用于调试或直接展示)。
/// </summary>
public string Message { get; set; } = string.Empty;
}
@@ -0,0 +1,87 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 登录请求。
/// </summary>
public class LoginRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 登录响应。
/// </summary>
public class LoginResponse
{
/// <summary>
/// Bearer Token(会话 ID)。
/// </summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// 会话过期时间(UTC)。
/// </summary>
public DateTime ExpiresAtUtc { get; set; }
/// <summary>
/// 用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 用户角色。
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// 用户权限列表。
/// </summary>
public List<string> Permissions { get; set; } = new();
}
/// <summary>
/// 初始化管理员账号请求(仅在系统尚无云用户时可用)。
/// </summary>
public class BootstrapAdminRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)请求。
/// </summary>
public class StepUpRequest
{
/// <summary>
/// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)响应。
/// </summary>
public class StepUpResponse
{
/// <summary>
/// 二次认证有效期截止(UTC)。
/// </summary>
public DateTime StepUpExpiresAtUtc { get; set; }
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 错误响应生成器。
/// </summary>
public static class CloudApiErrors
{
/// <summary>
/// 生成标准错误响应。
/// </summary>
/// <param name="statusCode">HTTP 状态码。</param>
/// <param name="code">业务错误码。</param>
/// <param name="message">错误消息。</param>
/// <returns>最小 API 结果。</returns>
public static IResult Error(int statusCode, string code, string message)
{
return Results.Json(
new ApiErrorResponse { Code = code, Message = message },
statusCode: statusCode);
}
/// <summary>
/// 未认证。
/// </summary>
public static IResult Unauthorized(string message = "Unauthorized.")
=> Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message);
/// <summary>
/// 权限不足。
/// </summary>
public static IResult Forbidden(string message = "Forbidden.")
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message);
/// <summary>
/// 需要二次认证(step-up)。
/// </summary>
public static IResult SecondFactorRequired(string message = "Second factor required.")
=> Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", message);
/// <summary>
/// 请求非法。
/// </summary>
public static IResult BadRequest(string message = "Bad request.")
=> Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message);
/// <summary>
/// 资源不存在。
/// </summary>
public static IResult NotFound(string message = "Not found.")
=> Error(StatusCodes.Status404NotFound, "NOT_FOUND", message);
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 安全策略下发 DTO。
/// </summary>
public class SecurityPolicyDto
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
}
/// <summary>
/// 更新安全策略请求 DTO。
/// </summary>
public class UpdateSecurityPolicyRequest
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
}
@@ -0,0 +1,108 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步任务条目。
/// </summary>
public class CloudTaskItem
{
/// <summary>
/// 任务 ID(服务端分配)。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; }
/// <summary>
/// 父任务 ID(v1.2.0 同步可先不使用)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
/// <summary>
/// 新增或更新的任务列表。
/// </summary>
public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary>
/// 需要删除的任务 ID 列表。
/// </summary>
public List<int> Deletes { get; set; } = new();
}
/// <summary>
/// 任务 Upsert DTO。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为空表示新建。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
/// <summary>
/// 服务端时间(UTC)。
/// </summary>
public DateTime ServerTimeUtc { get; set; }
/// <summary>
/// 当前用户的任务全量。
/// </summary>
public List<CloudTaskItem> Tasks { get; set; } = new();
}
@@ -0,0 +1,188 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 云同步认证服务(登录、初始化管理员、二次认证)。
/// </summary>
public class CloudAuthService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="CloudAuthService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public CloudAuthService(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_rolePermissionMapper = rolePermissionMapper;
}
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return false;
}
var hasAnyCloudUser = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.Role != "local", cancellationToken);
if (hasAnyCloudUser)
{
return false;
}
var exists = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists)
{
return false;
}
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin"
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
_dbContext.Users.Add(user);
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// 用户名密码登录并创建会话。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="sessionTtl">会话有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录响应;失败则返回 null。</returns>
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (user == null || user.Role == "local")
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
return null;
}
var now = DateTime.UtcNow;
var expiresAt = now.Add(sessionTtl);
var session = new UserSessionEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
CreatedAtUtc = now,
ExpiresAtUtc = expiresAt
};
_dbContext.UserSessions.Add(session);
var hasPolicy = await _dbContext.SecurityPolicies
.AsNoTracking()
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
if (!hasPolicy)
{
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new LoginResponse
{
AccessToken = session.Id.ToString(),
ExpiresAtUtc = expiresAt,
UserId = user.Id,
Role = user.Role,
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList()
};
}
/// <summary>
/// 通过再输入口令提升会话权限(step-up)。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</param>
/// <param name="stepUpTtl">二次认证有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
return null;
}
var stepUpExpiresAt = now.Add(stepUpTtl);
session.StepUpExpiresAtUtc = stepUpExpiresAt;
await _dbContext.SaveChangesAsync(cancellationToken);
return stepUpExpiresAt;
}
}
@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 云同步任务服务(按用户隔离)。
/// </summary>
public class CloudTaskSyncService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public CloudTaskSyncService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的任务全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>任务列表。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{
var tasks = await _dbContext.Tasks
.AsNoTracking()
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync(cancellationToken);
return tasks.Select(MapToItem).ToList();
}
/// <summary>
/// 执行同步(增改删),并返回最新全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="request">同步请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>同步响应。</returns>
public async Task<SyncResponse> SyncAsync(Guid userId, SyncRequest request, CancellationToken cancellationToken)
{
request ??= new SyncRequest();
if (request.Deletes.Count > 0)
{
var deleteIds = request.Deletes.Distinct().ToList();
var toDelete = await _dbContext.Tasks
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
.ToListAsync(cancellationToken);
if (toDelete.Count > 0)
{
_dbContext.Tasks.RemoveRange(toDelete);
}
}
if (request.Upserts.Count > 0)
{
foreach (var upsert in request.Upserts)
{
if (string.IsNullOrWhiteSpace(upsert.Title))
{
continue;
}
if (upsert.Id.HasValue)
{
var existing = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (existing != null)
{
existing.Title = upsert.Title.Trim();
existing.Priority = upsert.Priority;
existing.IsCompleted = upsert.IsCompleted;
existing.ParentTaskId = upsert.ParentTaskId;
existing.UpdatedAt = DateTime.UtcNow;
continue;
}
}
var entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = upsert.ParentTaskId
};
_dbContext.Tasks.Add(entity);
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new SyncResponse
{
ServerTimeUtc = DateTime.UtcNow,
Tasks = await GetTasksAsync(userId, cancellationToken)
};
}
private static CloudTaskItem MapToItem(TaskEntity task)
{
return new CloudTaskItem
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAtUtc = task.CreatedAt,
UpdatedAtUtc = task.UpdatedAt,
ParentTaskId = task.ParentTaskId
};
}
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 安全策略服务(按用户隔离)。
/// </summary>
public class SecurityPolicyService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的安全策略。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>策略 DTO。</returns>
public async Task<SecurityPolicyDto> GetPolicyAsync(Guid userId, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies
.AsNoTracking()
.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
return new SecurityPolicyDto
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
};
}
/// <summary>
/// 更新指定用户的安全策略(覆盖式更新)。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
if (policy == null)
{
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync };
_dbContext.SecurityPolicies.Add(policy);
}
else
{
policy.AllowPersist = allowPersist;
policy.AllowSync = allowSync;
}
await _dbContext.SaveChangesAsync(cancellationToken);
return await GetPolicyAsync(userId, cancellationToken);
}
}
@@ -21,6 +21,21 @@ public class TodoDbContext : DbContext
/// </summary>
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 用户集合。
/// </summary>
public DbSet<UserEntity> Users { get; set; }
/// <summary>
/// 用户会话集合。
/// </summary>
public DbSet<UserSessionEntity> UserSessions { get; set; }
/// <summary>
/// 安全策略集合。
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
@@ -33,16 +48,58 @@ public class TodoDbContext : DbContext
{
entity.ToTable("Tasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')");
entity.HasOne(e => e.User)
.WithMany(u => u.Tasks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserName).IsRequired().HasMaxLength(64);
entity.HasIndex(e => e.UserName).IsUnique();
entity.Property(e => e.PasswordHash).IsRequired();
entity.Property(e => e.Role).IsRequired().HasMaxLength(32);
});
modelBuilder.Entity<UserSessionEntity>(entity =>
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
entity.Property(e => e.CreatedAtUtc).IsRequired();
entity.Property(e => e.ExpiresAtUtc).IsRequired();
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SecurityPolicyEntity>(entity =>
{
entity.ToTable("SecurityPolicies");
entity.HasKey(e => e.Id);
entity.Property(e => e.AllowPersist).HasDefaultValue(true);
entity.Property(e => e.AllowSync).HasDefaultValue(true);
entity.HasIndex(e => e.UserId).IsUnique();
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -13,6 +13,7 @@
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="CloudSync\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,198 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260406172936_AddCloudSyncCoreEntities")]
partial class AddCloudSyncCoreEntities
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,136 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddCloudSyncCoreEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "UserId",
table: "Tasks",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000001"));
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "UserName", "PasswordHash", "Role" },
values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "local", "", "local" });
migrationBuilder.CreateTable(
name: "SecurityPolicies",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AllowPersist = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecurityPolicies", x => x.Id);
table.ForeignKey(
name: "FK_SecurityPolicies_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StepUpExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSessions", x => x.Id);
table.ForeignKey(
name: "FK_UserSessions_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tasks_UserId",
table: "Tasks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SecurityPolicies_UserId",
table: "SecurityPolicies",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_UserName",
table: "Users",
column: "UserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_UserId",
table: "UserSessions",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks");
migrationBuilder.DropTable(
name: "SecurityPolicies");
migrationBuilder.DropTable(
name: "UserSessions");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropIndex(
name: "IX_Tasks_UserId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "UserId",
table: "Tasks");
}
}
}
@@ -0,0 +1,203 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260406173734_AddAllowSyncToSecurityPolicy")]
partial class AddAllowSyncToSecurityPolicy
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAllowSyncToSecurityPolicy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSync",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSync",
table: "SecurityPolicies");
}
}
}
@@ -1,9 +1,9 @@
// <auto-generated />
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
@@ -17,6 +17,33 @@ namespace Hua.Todo.Application.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
@@ -51,11 +78,82 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
@@ -65,13 +163,37 @@ namespace Hua.Todo.Application.Migrations
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
@@ -28,6 +28,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId)
.Include(t => t.SubTasks)
.ToListAsync();
}
@@ -41,7 +42,7 @@ public class TaskRepository : ITaskRepository
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
}
/// <summary>
@@ -51,7 +52,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
@@ -63,7 +64,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
@@ -75,6 +76,7 @@ public class TaskRepository : ITaskRepository
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
taskEntity.UserId = TodoUserIds.LocalUserId;
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
@@ -100,7 +102,7 @@ public class TaskRepository : ITaskRepository
/// <returns>表示删除操作的任务。</returns>
public async Task DeleteAsync(int id)
{
var task = await _context.Tasks.FindAsync(id);
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
if (task != null)
{
_context.Tasks.Remove(task);
@@ -116,7 +118,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
+33
View File
@@ -0,0 +1,33 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Hua.Todo.Avalonia.App"
xmlns:local="using:Hua.Todo.Avalonia"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
x:DataType="vm:AppTrayViewModel"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="{Binding TrayTooltipText}"
Command="{Binding ShowMainWindowCommand}">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+270
View File
@@ -0,0 +1,270 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using AvaloniaWebView;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using Hua.Todo.Avalonia.Services.Platforms;
using Hua.Todo.Avalonia.ViewModels;
using Hua.Todo.Avalonia.Views;
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace Hua.Todo.Avalonia;
/// <summary>
/// Avalonia 应用入口。
/// 负责启动嵌入式 WebServer、创建主窗口并初始化 WebView 能力。
/// </summary>
public partial class App : global::Avalonia.Application
{
private AppSettings? _appSettings;
private IEmbeddedWebServerService? _webServer;
private IHotKeySettingsService? _hotKeySettingsService;
private IGlobalHotKeyService? _hotKeyService;
private HotKeyConfig? _hotKeyConfig;
private WindowsKeyboardHandler? _keyboardHandler;
private MainWindow? _mainWindow;
private bool _isExitRequested;
private bool _isHotkeyRegistered;
/// <summary>
/// 初始化应用资源并加载配置。
/// </summary>
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
LoadSettings();
}
/// <summary>
/// 注册应用级服务。
/// </summary>
public override void RegisterServices()
{
base.RegisterServices();
AvaloniaWebViewBuilder.Initialize(default);
}
private void LoadSettings()
{
try
{
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
_appSettings = JsonSerializer.Deserialize<AppSettings>(json);
}
}
catch (Exception ex)
{
Console.WriteLine($"[App] Failed to load settings: {ex.Message}");
}
_appSettings ??= new AppSettings();
EnsureDefaultConnectionString(_appSettings);
}
private static void EnsureDefaultConnectionString(AppSettings appSettings)
{
if (string.IsNullOrWhiteSpace(appSettings.WebServer.ConnectionString))
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dbDir = Path.Combine(localAppData, "Hua.Todo");
if (!Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
var dbPath = Path.Combine(dbDir, "Hua.Todo.db");
appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared";
}
}
/// <summary>
/// Avalonia 框架初始化完成回调。
/// 启动嵌入式 WebServer(后台启动并捕获异常,避免阻塞/崩溃),并创建主窗口。
/// </summary>
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
DisableAvaloniaDataAnnotationValidation();
_webServer = new EmbeddedWebServerService(_appSettings!);
_ = Task.Run(async () =>
{
try
{
await _webServer.StartAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[App] Web server start failed: {ex}");
}
});
_hotKeySettingsService = new HotKeySettingsService(_appSettings!);
_hotKeyConfig = _hotKeySettingsService.GetConfig();
_hotKeyService = GlobalHotKeyServiceFactory.Create();
if (OperatingSystem.IsWindows())
{
_keyboardHandler = new WindowsKeyboardHandler();
_keyboardHandler.EscKeyPressed += (_, _) =>
{
Dispatcher.UIThread.Post(() =>
{
if (_mainWindow is { IsVisible: true })
{
_mainWindow.WindowState = WindowState.Minimized;
}
});
};
}
DataContext = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
_mainWindow = new MainWindow(_appSettings!, _webServer)
{
Width = 450,
Height = 640,
Title = AppMetadata.GetWindowTitle(),
};
_mainWindow.Opened += (_, _) =>
{
RegisterHotkey();
_keyboardHandler?.Start();
};
_mainWindow.Closing += (_, e) =>
{
if (_isExitRequested)
{
return;
}
e.Cancel = true;
_mainWindow.Hide();
};
desktop.MainWindow = _mainWindow;
desktop.Exit += async (s, e) =>
{
_keyboardHandler?.Dispose();
_keyboardHandler = null;
if (_isHotkeyRegistered)
{
_hotKeyService?.UnregisterHotKey();
_isHotkeyRegistered = false;
}
if (_hotKeyService is IDisposable disposableHotKeyService)
{
disposableHotKeyService.Dispose();
}
if (_webServer != null)
{
await _webServer.StopAsync();
}
};
}
base.OnFrameworkInitializationCompleted();
}
private void ExitApplication(IClassicDesktopStyleApplicationLifetime desktop)
{
_isExitRequested = true;
desktop.Shutdown();
}
private void ShowMainWindow()
{
if (_mainWindow == null) return;
if (!_mainWindow.IsVisible)
{
_mainWindow.Show();
}
if (_mainWindow.WindowState == WindowState.Minimized)
{
_mainWindow.WindowState = WindowState.Normal;
}
_mainWindow.Activate();
}
private void RegisterHotkey()
{
if (_hotKeyService == null || _hotKeySettingsService == null) return;
var config = _hotKeyConfig ?? _hotKeySettingsService.GetConfig();
_hotKeyConfig = config;
if (_hotKeyService.IsSupported && config.IsEnabled)
{
_hotKeyService.RegisterHotKey(config.Modifiers, config.Key, () =>
{
Dispatcher.UIThread.Post(ShowMainWindow);
});
_isHotkeyRegistered = true;
}
config.PropertyChanged -= OnHotKeyConfigPropertyChanged;
config.PropertyChanged += OnHotKeyConfigPropertyChanged;
}
private void OnHotKeyConfigPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_hotKeyService == null || _hotKeySettingsService == null || _hotKeyConfig == null) return;
if (e.PropertyName != nameof(HotKeyConfig.Modifiers) &&
e.PropertyName != nameof(HotKeyConfig.Key) &&
e.PropertyName != nameof(HotKeyConfig.IsEnabled))
{
return;
}
_hotKeySettingsService.SaveConfig(_hotKeyConfig);
if (_hotKeyConfig.IsEnabled && _hotKeyService.IsSupported)
{
_hotKeyService.UpdateHotKey(_hotKeyConfig.Modifiers, _hotKeyConfig.Key);
_isHotkeyRegistered = true;
}
else
{
_hotKeyService.UnregisterHotKey();
_isHotkeyRegistered = false;
}
}
private void DisableAvaloniaDataAnnotationValidation()
{
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,64 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<TodoWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web'))</TodoWebDir>
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<ForceWebBuild>false</ForceWebBuild>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<Target Name="BuildTodoWeb" BeforeTargets="BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoWebDistDir)\index.html'))">
<Exec Command="npm ci" WorkingDirectory="$(TodoWebDir)" Condition="Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm install" WorkingDirectory="$(TodoWebDir)" Condition="!Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm run build" WorkingDirectory="$(TodoWebDir)" />
</Target>
<Target Name="CopyTodoWebDistToWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="Exists('$(TodoWebDistDir)')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
<MakeDir Directories="$(TargetDir)wwwroot" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace Hua.Todo.Avalonia.Models;
public class AppSettings
{
[JsonPropertyName("WebServer")]
public WebServerSettings WebServer { get; set; } = new();
[JsonPropertyName("HotKey")]
public HotKeyDefaultSettings HotKey { get; set; } = new();
}
public class WebServerSettings
{
[JsonPropertyName("Port")]
public int Port { get; set; } = 5057;
[JsonPropertyName("IsUsingStatic")]
public bool IsUsingStatic { get; set; } = true;
[JsonPropertyName("ConnectionString")]
public string ConnectionString { get; set; } = "";
[JsonPropertyName("HostUrl")]
public string HostUrl { get; set; } = "http://localhost:5057";
[JsonPropertyName("ForEndUrl")]
public string ForEndUrl { get; set; } = "http://localhost:5174";
}
public class HotKeyDefaultSettings
{
[JsonPropertyName("DefaultModifiers")]
public string DefaultModifiers { get; set; } = "Alt";
[JsonPropertyName("DefaultKey")]
public string DefaultKey { get; set; } = "X";
[JsonPropertyName("DefaultIsEnabled")]
public bool DefaultIsEnabled { get; set; } = true;
}
@@ -0,0 +1,72 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Hua.Todo.Avalonia.Models;
/// <summary>
/// 热键配置模型。
/// 用于描述全局热键的修饰键、主键与启用状态,并通过通知机制驱动动态更新。
/// </summary>
public sealed class HotKeyConfig : INotifyPropertyChanged
{
private string _modifiers = "Alt";
private string _key = "X";
private bool _isEnabled = true;
/// <summary>
/// 修饰键(例如 Alt、Control、Shift、Windows;多个键用逗号分隔)。
/// </summary>
public string Modifiers
{
get => _modifiers;
set
{
if (_modifiers != value)
{
_modifiers = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 主键(例如 X、C、V)。
/// </summary>
public string Key
{
get => _key;
set
{
if (_key != value)
{
_key = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 是否启用热键。
/// </summary>
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
+23
View File
@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.WebView.Desktop;
using System;
namespace Hua.Todo.Avalonia;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseDesktopWebView();
}
@@ -0,0 +1,60 @@
using System.Reflection;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 应用元信息工具类。
/// 负责生成窗口标题与托盘提示文本等展示字符串,避免在多处重复拼装并保持跨宿主一致性。
/// </summary>
public static class AppMetadata
{
private const string AppNameText = "待办事项";
/// <summary>
/// 应用名称(不含版本)。
/// </summary>
public static string AppName => AppNameText;
/// <summary>
/// 获取显示版本号(主版本.次版本.修订版本)。
/// 若无法解析版本信息则返回 null。
/// </summary>
public static string? GetDisplayVersion()
{
var asmVersion = Assembly.GetExecutingAssembly().GetName().Version;
if (asmVersion == null)
{
return null;
}
return $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}";
}
/// <summary>
/// 获取托盘/关于等场景使用的显示标题(可能包含版本)。
/// </summary>
public static string GetDisplayTitle()
{
var version = GetDisplayVersion();
return string.IsNullOrWhiteSpace(version) ? AppName : $"{AppName} v{version}";
}
/// <summary>
/// 获取窗口标题栏文本(可能包含版本)。
/// </summary>
public static string GetWindowTitle()
{
var version = GetDisplayVersion();
return string.IsNullOrWhiteSpace(version) ? AppNameText : $"{AppNameText} v{version}";
}
/// <summary>
/// 获取托盘 Tooltip 文本(部分平台对长度有限制)。
/// </summary>
public static string GetTrayTooltipText()
{
var text = GetDisplayTitle();
return text.Length > 63 ? text[..63] : text;
}
}
@@ -0,0 +1,208 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Hua.Todo.Application;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Avalonia.Models;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 嵌入式 Web 服务器实现
/// </summary>
public class EmbeddedWebServerService : IEmbeddedWebServerService
{
private WebApplication? _webApp;
private readonly AppSettings _appSettings;
/// <summary>
/// 服务器是否正在运行
/// </summary>
public bool IsRunning => _webApp != null;
/// <summary>
/// 服务器基础 URL
/// </summary>
public string BaseUrl => _appSettings.WebServer.HostUrl;
/// <summary>
/// 初始化嵌入式 Web 服务器服务
/// </summary>
/// <param name="appSettings">应用程序配置</param>
public EmbeddedWebServerService(AppSettings appSettings)
{
_appSettings = appSettings;
}
/// <summary>
/// 异步启动服务器
/// </summary>
public async Task StartAsync()
{
if (_webApp != null) return;
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl);
// 配置控制器和 JSON 选项
builder.Services.AddControllers()
.AddApplicationPart(typeof(Hua.Todo.Application.ServiceCollectionExtensions).Assembly)
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddEndpointsApiExplorer();
// 注册应用逻辑服务
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
// 配置跨域策略
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
InitializeDatabase(app);
// 如果配置为使用静态文件(前端托管),则配置静态文件服务
if (_appSettings.WebServer.IsUsingStatic)
{
ServeStaticFiles(app);
}
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseDynamicApi();
app.MapControllers();
_webApp = app;
await _webApp.StartAsync();
}
private void InitializeDatabase(WebApplication app)
{
using var scope = app.Services.CreateScope();
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString);
var actualDbPath = sqliteBuilder.DataSource;
if (!string.IsNullOrEmpty(actualDbPath))
{
var dbDir = Path.GetDirectoryName(actualDbPath);
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
}
dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
dbContext.Database.Migrate();
}
catch (Exception ex)
{
Console.WriteLine($"[EmbeddedWebServer] Database initialization failed: {ex.Message}");
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
dbContext.Database.EnsureCreated();
}
catch (Exception ensureEx)
{
Console.WriteLine($"[EmbeddedWebServer] Database ensure-created failed: {ensureEx.Message}");
}
}
}
/// <summary>
/// 配置静态文件服务(用于托管 Vue 前端)
/// </summary>
/// <param name="app">Web 应用程序实例</param>
private void ServeStaticFiles(WebApplication app)
{
try
{
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(wwwrootPath))
{
Console.WriteLine($"[EmbeddedWebServer] wwwroot directory not found at: {wwwrootPath}. Static file serving disabled.");
return;
}
var fileProvider = new PhysicalFileProvider(wwwrootPath);
var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider, RequestPath = "" };
app.UseDefaultFiles(defaultFilesOptions);
var staticFileOptions = new StaticFileOptions
{
FileProvider = fileProvider,
RequestPath = "",
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
ctx.Context.Response.Headers["Pragma"] = "no-cache";
ctx.Context.Response.Headers["Expires"] = "0";
}
};
app.UseStaticFiles(staticFileOptions);
// 处理 SPA 路由
app.Use(async (context, next) =>
{
if (context.Request.Path.HasValue)
{
var path = context.Request.Path.Value;
if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
{
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
{
context.Request.Path = "/index.html";
}
}
}
await next();
});
Console.WriteLine($"[EmbeddedWebServer] Serving static files from: {wwwrootPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[EmbeddedWebServer] Failed to serve static files: {ex.Message}");
}
}
/// <summary>
/// 异步停止服务器
/// </summary>
public async Task StopAsync()
{
if (_webApp == null) return;
await _webApp.StopAsync();
await _webApp.DisposeAsync();
_webApp = null;
}
}
@@ -0,0 +1,25 @@
using Hua.Todo.Avalonia.Services.Platforms;
using System;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 全局热键服务工厂。
/// 通过运行时平台判断返回最合适的实现,避免在同一文件内混写大量条件编译代码。
/// </summary>
public static class GlobalHotKeyServiceFactory
{
/// <summary>
/// 创建平台对应的全局热键服务实现。
/// </summary>
public static IGlobalHotKeyService Create()
{
if (OperatingSystem.IsWindows())
{
return new WindowsGlobalHotKeyService();
}
return new NoopGlobalHotKeyService();
}
}
@@ -0,0 +1,130 @@
using Hua.Todo.Avalonia.Models;
using System;
using System.IO;
using System.Text.Json;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 热键设置服务接口。
/// 负责读取/保存用户可配置的全局热键,并提供从默认值重置的能力。
/// </summary>
public interface IHotKeySettingsService
{
/// <summary>
/// 获取当前热键配置(若尚未持久化则返回默认配置)。
/// </summary>
HotKeyConfig GetConfig();
/// <summary>
/// 保存热键配置。
/// </summary>
/// <param name="config">要保存的热键配置。</param>
void SaveConfig(HotKeyConfig config);
/// <summary>
/// 重置为默认配置并落盘。
/// </summary>
void ResetToDefault();
}
/// <summary>
/// 热键设置服务实现。
/// Avalonia 侧使用本地 JSON 文件持久化配置,以保证桌面端与多平台运行时一致性。
/// </summary>
public sealed class HotKeySettingsService : IHotKeySettingsService
{
private const string SettingsFileName = "hotkey.json";
private readonly AppSettings _appSettings;
private HotKeyConfig? _cached;
/// <summary>
/// 创建 <see cref="HotKeySettingsService"/>。
/// </summary>
/// <param name="appSettings">应用配置(用于提供默认热键)。</param>
public HotKeySettingsService(AppSettings appSettings)
{
_appSettings = appSettings;
}
/// <inheritdoc />
public HotKeyConfig GetConfig()
{
if (_cached != null)
{
return _cached;
}
var path = GetSettingsPath();
if (!File.Exists(path))
{
_cached = GetDefaultConfig();
return _cached;
}
try
{
var json = File.ReadAllText(path);
_cached = JsonSerializer.Deserialize<HotKeyConfig>(json) ?? GetDefaultConfig();
return _cached;
}
catch
{
_cached = GetDefaultConfig();
return _cached;
}
}
/// <inheritdoc />
public void SaveConfig(HotKeyConfig config)
{
var path = GetSettingsPath();
EnsureSettingsDirectory(path);
var json = JsonSerializer.Serialize(config);
File.WriteAllText(path, json);
_cached ??= config;
}
/// <inheritdoc />
public void ResetToDefault()
{
var defaultConfig = GetDefaultConfig();
var current = GetConfig();
current.Modifiers = defaultConfig.Modifiers;
current.Key = defaultConfig.Key;
current.IsEnabled = defaultConfig.IsEnabled;
SaveConfig(current);
}
private HotKeyConfig GetDefaultConfig()
{
return new HotKeyConfig
{
Modifiers = _appSettings.HotKey.DefaultModifiers,
Key = _appSettings.HotKey.DefaultKey,
IsEnabled = _appSettings.HotKey.DefaultIsEnabled
};
}
private static string GetSettingsPath()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dir = Path.Combine(localAppData, "Hua.Todo");
return Path.Combine(dir, SettingsFileName);
}
private static void EnsureSettingsDirectory(string settingsPath)
{
var dir = Path.GetDirectoryName(settingsPath);
if (string.IsNullOrWhiteSpace(dir))
{
return;
}
Directory.CreateDirectory(dir);
}
}
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 嵌入式 Web 服务器接口
/// </summary>
public interface IEmbeddedWebServerService
{
/// <summary>
/// 服务器是否正在运行
/// </summary>
bool IsRunning { get; }
/// <summary>
/// 服务器基础 URL
/// </summary>
string BaseUrl { get; }
/// <summary>
/// 异步启动服务器
/// </summary>
Task StartAsync();
/// <summary>
/// 异步停止服务器
/// </summary>
Task StopAsync();
}
@@ -0,0 +1,36 @@
using System;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 全局热键服务接口。
/// 用于在桌面环境下注册系统级快捷键,以便在应用隐藏/最小化时快速唤起主窗口。
/// </summary>
public interface IGlobalHotKeyService
{
/// <summary>
/// 注册全局热键。
/// </summary>
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
/// <param name="key">主键(如 X、C)。</param>
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
void RegisterHotKey(string modifiers, string key, Action callback);
/// <summary>
/// 注销已注册的热键。
/// </summary>
void UnregisterHotKey();
/// <summary>
/// 更新热键配置。
/// </summary>
/// <param name="modifiers">新的修饰键。</param>
/// <param name="key">新的主键。</param>
void UpdateHotKey(string modifiers, string key);
/// <summary>
/// 当前平台是否支持全局热键。
/// </summary>
bool IsSupported { get; }
}
@@ -0,0 +1,29 @@
using System;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 不支持全局热键的平台默认实现。
/// 该实现不会注册任何系统级热键,用于保持上层逻辑简洁一致。
/// </summary>
public sealed class NoopGlobalHotKeyService : IGlobalHotKeyService
{
/// <inheritdoc />
public void RegisterHotKey(string modifiers, string key, Action callback)
{
}
/// <inheritdoc />
public void UnregisterHotKey()
{
}
/// <inheritdoc />
public void UpdateHotKey(string modifiers, string key)
{
}
/// <inheritdoc />
public bool IsSupported => false;
}
@@ -0,0 +1,264 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Windows 平台全局热键服务实现。
/// 通过 <c>RegisterHotKey</c> 将热键注册到专用线程的消息队列中,避免依赖窗口句柄与 WndProc Hook。
/// </summary>
public sealed class WindowsGlobalHotKeyService : IGlobalHotKeyService, IDisposable
{
private const int HotKeyId = 9000;
private const uint WmHotKey = 0x0312;
private const uint WmQuit = 0x0012;
private const uint WmAppExecute = 0x8001;
public const uint ModAlt = 0x0001;
public const uint ModControl = 0x0002;
public const uint ModShift = 0x0004;
public const uint ModWin = 0x0008;
private readonly ConcurrentDictionary<int, Action> _actions = new();
private int _actionId;
private Thread? _thread;
private uint _threadId;
private ManualResetEventSlim? _threadStarted;
private Action? _callback;
private bool _isRegistered;
private uint _currentModifiers;
private uint _currentKey;
private bool _isDisposed;
/// <inheritdoc />
public bool IsSupported => OperatingSystem.IsWindows();
/// <inheritdoc />
public void RegisterHotKey(string modifiers, string key, Action callback)
{
if (!IsSupported) return;
_callback = callback;
_currentModifiers = ParseModifiers(modifiers);
_currentKey = ParseKey(key);
StartThreadIfNeeded();
InvokeOnHotKeyThread(() =>
{
UnregisterHotKeyInternal();
if (_currentKey == 0)
{
return;
}
if (RegisterHotKey(IntPtr.Zero, HotKeyId, _currentModifiers, _currentKey))
{
_isRegistered = true;
}
else
{
Debug.WriteLine("[WindowsGlobalHotKeyService] Failed to register hotkey.");
}
});
}
/// <inheritdoc />
public void UnregisterHotKey()
{
if (!IsSupported) return;
StartThreadIfNeeded();
InvokeOnHotKeyThread(UnregisterHotKeyInternal);
}
/// <inheritdoc />
public void UpdateHotKey(string modifiers, string key)
{
if (_callback == null) return;
RegisterHotKey(modifiers, key, _callback);
}
private void UnregisterHotKeyInternal()
{
if (_isRegistered)
{
UnregisterHotKey(IntPtr.Zero, HotKeyId);
_isRegistered = false;
}
}
private void StartThreadIfNeeded()
{
if (_thread != null) return;
_threadStarted = new ManualResetEventSlim(false);
_thread = new Thread(MessageLoop)
{
IsBackground = true,
Name = "Hua.Todo HotKey Thread"
};
if (OperatingSystem.IsWindows())
{
_thread.SetApartmentState(ApartmentState.STA);
}
_thread.Start();
_threadStarted.Wait();
}
private void MessageLoop()
{
_threadId = GetCurrentThreadId();
_threadStarted?.Set();
while (GetMessage(out var msg, IntPtr.Zero, 0, 0) != 0)
{
if (msg.message == WmHotKey && msg.wParam == (UIntPtr)HotKeyId)
{
try
{
_callback?.Invoke();
}
catch (Exception ex)
{
Debug.WriteLine($"[WindowsGlobalHotKeyService] Hotkey callback failed: {ex}");
}
continue;
}
if (msg.message == WmAppExecute)
{
var id = unchecked((int)msg.wParam);
if (_actions.TryRemove(id, out var action))
{
try
{
action();
}
catch (Exception ex)
{
Debug.WriteLine($"[WindowsGlobalHotKeyService] Action execution failed: {ex}");
}
}
continue;
}
if (msg.message == WmQuit)
{
break;
}
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
_threadStarted?.Dispose();
_threadStarted = null;
}
private void InvokeOnHotKeyThread(Action action)
{
var id = Interlocked.Increment(ref _actionId);
_actions[id] = action;
if (!PostThreadMessage(_threadId, WmAppExecute, (UIntPtr)id, IntPtr.Zero))
{
_actions.TryRemove(id, out _);
}
}
private static uint ParseModifiers(string modifiers)
{
uint mod = 0;
if (string.IsNullOrWhiteSpace(modifiers)) return mod;
var parts = modifiers.Split(',');
foreach (var part in parts)
{
var p = part.Trim();
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= ModControl;
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= ModAlt;
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= ModShift;
if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= ModWin;
}
return mod;
}
private static uint ParseKey(string key)
{
if (string.IsNullOrWhiteSpace(key)) return 0;
if (key.Length == 1)
{
var c = char.ToUpperInvariant(key[0]);
if (c is >= 'A' and <= 'Z') return c;
if (c is >= '0' and <= '9') return c;
}
return 0x58; // Default 'X'
}
/// <inheritdoc />
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
try
{
UnregisterHotKey();
}
catch
{
}
if (_threadId != 0)
{
PostThreadMessage(_threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero);
}
}
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
public IntPtr hwnd;
public uint message;
public UIntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll")]
private static extern sbyte GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref Msg lpMsg);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool PostThreadMessage(uint idThread, uint msg, UIntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
}
@@ -0,0 +1,138 @@
using System;
using System.Runtime.InteropServices;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Windows 全局键盘事件处理器。
/// 用于监听按键并向上层暴露事件(例如 Esc),以保证在 WebView 获得焦点时依旧可触发窗口级交互。
/// </summary>
public sealed class WindowsKeyboardHandler : IDisposable
{
private readonly KeyboardHook _keyboardHook;
private bool _isDisposed;
/// <summary>
/// 当检测到 Esc 键抬起时触发。
/// </summary>
public event EventHandler? EscKeyPressed;
/// <summary>
/// 创建 <see cref="WindowsKeyboardHandler"/>。
/// </summary>
public WindowsKeyboardHandler()
{
_keyboardHook = new KeyboardHook();
_keyboardHook.KeyPressed += OnKeyPressed;
}
/// <summary>
/// 开始监听键盘事件。
/// </summary>
public void Start()
{
_keyboardHook.Hook();
}
private void OnKeyPressed(object? sender, KeyPressedEventArgs e)
{
if (e.Key == 0x1B && !e.IsKeyDown)
{
EscKeyPressed?.Invoke(this, EventArgs.Empty);
}
}
/// <inheritdoc />
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
_keyboardHook.Unhook();
_keyboardHook.KeyPressed -= OnKeyPressed;
}
private sealed class KeyboardHook : IDisposable
{
private const int WhKeyboardLl = 13;
private const int WmKeyDown = 0x0100;
private const int WmKeyUp = 0x0101;
private const int WmSysKeyDown = 0x0104;
private const int WmSysKeyUp = 0x0105;
private readonly LowLevelKeyboardProc _proc;
private IntPtr _hookId = IntPtr.Zero;
private bool _isDisposed;
public event EventHandler<KeyPressedEventArgs>? KeyPressed;
public KeyboardHook()
{
_proc = HookCallback;
}
public void Hook()
{
if (_hookId != IntPtr.Zero) return;
_hookId = SetWindowsHookEx(WhKeyboardLl, _proc, GetModuleHandle(null), 0);
}
public void Unhook()
{
if (_hookId == IntPtr.Zero) return;
UnhookWindowsHookEx(_hookId);
_hookId = IntPtr.Zero;
}
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
var vkCode = Marshal.ReadInt32(lParam);
var isKeyDown = wParam == (IntPtr)WmKeyDown || wParam == (IntPtr)WmSysKeyDown;
var isKeyUp = wParam == (IntPtr)WmKeyUp || wParam == (IntPtr)WmSysKeyUp;
if (isKeyDown || isKeyUp)
{
KeyPressed?.Invoke(this, new KeyPressedEventArgs(vkCode, isKeyDown));
}
}
return CallNextHookEx(_hookId, nCode, wParam, lParam);
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
Unhook();
}
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
}
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
private sealed class KeyPressedEventArgs : EventArgs
{
public int Key { get; }
public bool IsKeyDown { get; }
public KeyPressedEventArgs(int key, bool isKeyDown)
{
Key = key;
IsKeyDown = isKeyDown;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Hua.Todo.Avalonia.ViewModels;
namespace Hua.Todo.Avalonia;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
@@ -0,0 +1,47 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
namespace Hua.Todo.Avalonia.ViewModels;
/// <summary>
/// 应用托盘交互的 ViewModel。
/// 用于为 <see cref="global::Avalonia.Controls.TrayIcon"/> 提供命令与提示文本,并将托盘事件路由到应用层逻辑。
/// </summary>
public sealed partial class AppTrayViewModel : ObservableObject
{
private readonly Action _showMainWindow;
private readonly Action _exitApplication;
/// <summary>
/// 创建 <see cref="AppTrayViewModel"/>。
/// </summary>
/// <param name="trayTooltipText">托盘提示文本(部分平台有长度限制)。</param>
/// <param name="showMainWindow">显示/激活主窗口回调。</param>
/// <param name="exitApplication">退出应用回调(应触发应用生命周期关闭)。</param>
public AppTrayViewModel(string trayTooltipText, Action showMainWindow, Action exitApplication)
{
TrayTooltipText = trayTooltipText;
_showMainWindow = showMainWindow;
_exitApplication = exitApplication;
ShowMainWindowCommand = new RelayCommand(_showMainWindow);
ExitApplicationCommand = new RelayCommand(_exitApplication);
}
/// <summary>
/// 托盘提示文本。
/// </summary>
public string TrayTooltipText { get; }
/// <summary>
/// 显示/激活主窗口命令。
/// </summary>
public IRelayCommand ShowMainWindowCommand { get; }
/// <summary>
/// 退出应用命令。
/// </summary>
public IRelayCommand ExitApplicationCommand { get; }
}
@@ -0,0 +1,6 @@
namespace Hua.Todo.Avalonia.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public string Greeting { get; } = "Welcome to Avalonia!";
}
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Hua.Todo.Avalonia.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
@@ -0,0 +1,21 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
x:Class="Hua.Todo.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Width="450"
Height="640"
Title="待办事项"
WindowStartupLocation="CenterScreen">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<WebView Name="MainWebView" />
</Window>
@@ -0,0 +1,106 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using System;
namespace Hua.Todo.Avalonia.Views;
/// <summary>
/// 应用主窗口。
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
/// </summary>
public partial class MainWindow : Window
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService _webServer;
/// <summary>
/// 创建用于设计器预览的主窗口实例。
/// </summary>
public MainWindow()
{
InitializeComponent();
_appSettings = new AppSettings();
_webServer = new EmbeddedWebServerService(_appSettings);
}
/// <summary>
/// 创建运行时主窗口实例。
/// </summary>
/// <param name="appSettings">应用配置。</param>
/// <param name="webServer">嵌入式 WebServer。</param>
public MainWindow(AppSettings appSettings, IEmbeddedWebServerService webServer)
{
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebView();
}
private void SetupWebView()
{
try
{
if (_appSettings.WebServer.IsUsingStatic)
{
MainWebView.Url = new Uri(_webServer.BaseUrl);
}
else
{
MainWebView.Url = new Uri(_appSettings.WebServer.ForEndUrl);
}
MainWebView.NavigationCompleted += async (s, e) =>
{
try
{
if (_webServer.IsRunning)
{
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.ExecuteScriptAsync(@"
window.mauiInterop = {
onHotKeyConfigUpdated: null,
openHotKeySettings: function(config) {
const event = new CustomEvent('openHotKeySettings', { detail: config });
window.dispatchEvent(event);
},
updateHotKeyConfig: function(modifiers, key, isEnabled) {
const event = new CustomEvent('updateHotKeyConfig', {
detail: { modifiers, key, isEnabled }
});
window.dispatchEvent(event);
}
};
window.addEventListener('hotKeyConfigChanged', function(e) {
if (window.mauiInterop.onHotKeyConfigUpdated) {
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
}
});
");
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}");
Content = new TextBlock
{
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Hua.Todo.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+14
View File
@@ -0,0 +1,14 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
},
"HotKey": {
"DefaultModifiers": "Alt",
"DefaultKey": "X",
"DefaultIsEnabled": true
}
}
@@ -0,0 +1,32 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 安全策略配置(服务端下发)。
/// </summary>
public class SecurityPolicyEntity
{
/// <summary>
/// 策略唯一标识符。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 策略所属用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 策略所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 是否允许客户端落盘(为 false 时客户端应进入“内存模式”,不产生本地持久化)。
/// </summary>
public bool AllowPersist { get; set; } = true;
/// <summary>
/// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。
/// </summary>
public bool AllowSync { get; set; } = true;
}
+11
View File
@@ -5,6 +5,17 @@ namespace Hua.Todo.Core.Entities;
/// </summary>
public class TaskEntity
{
/// <summary>
/// 任务所属用户 ID。
/// 本地模式下使用固定的“本地用户”ID,以确保本地任务与云端多用户任务隔离。
/// </summary>
public Guid UserId { get; set; } = TodoUserIds.LocalUserId;
/// <summary>
/// 任务所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 任务唯一标识符
/// </summary>
+13
View File
@@ -0,0 +1,13 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 系统内置用户 ID 常量。
/// </summary>
public static class TodoUserIds
{
/// <summary>
/// 本地模式专用的内置用户 ID。
/// </summary>
public static readonly Guid LocalUserId = Guid.Parse("00000000-0000-0000-0000-000000000001");
}
+33
View File
@@ -0,0 +1,33 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 用户实体,用于云同步场景下的用户隔离与权限控制。
/// </summary>
public class UserEntity
{
/// <summary>
/// 用户唯一标识符。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 登录用户名(全局唯一)。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 登录密码哈希(不存储明文)。
/// </summary>
public string PasswordHash { get; set; } = string.Empty;
/// <summary>
/// 用户角色(用于 RBAC 权限映射)。
/// </summary>
public string Role { get; set; } = "user";
/// <summary>
/// 用户任务集合。
/// </summary>
public List<TaskEntity> Tasks { get; set; } = new();
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 用户会话实体(服务端会话令牌)。
/// </summary>
public class UserSessionEntity
{
/// <summary>
/// 会话唯一标识符,同时作为 Bearer Token 值使用。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 会话所属用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 会话所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 会话创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 会话过期时间(UTC)。
/// </summary>
public DateTime ExpiresAtUtc { get; set; }
/// <summary>
/// 二次认证(step-up)有效期截止(UTC);为空表示未进行二次认证。
/// </summary>
public DateTime? StepUpExpiresAtUtc { get; set; }
}
+4 -1
View File
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
@@ -9,7 +10,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthorization();
builder.Services.AddCloudSyncServer();
builder.Services.AddApplicationServices("Data Source=Hua.Todo.db");
@@ -40,7 +41,9 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseCors("AllowAll");
app.UseAuthentication();
app.UseAuthorization();
app.MapCloudSyncEndpoints();
app.UseDynamicApi();
app.Run();
+23 -12
View File
@@ -29,7 +29,7 @@
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<!-- Versions -->
<Version>1.1.5</Version>
<Version>1.1.9</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
@@ -95,8 +95,8 @@
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Include="icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<MauiImage Update="Resources\Images\icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\icon.jpg" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
@@ -146,13 +146,19 @@
<Folder Include="wwwroot\" />
</ItemGroup>
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoWebDistDir)\index.html'))">
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<!-- Windows embedded server serves static files from AppContext.BaseDirectory\wwwroot. -->
<Content Include="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<!-- Build web assets into MAUI `wwwroot` so both Windows publish and Android packaging can reuse the same folder. -->
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(MSBuildProjectDirectory)\wwwroot\index.html'))">
<Exec Command="npm ci" WorkingDirectory="$(TodoWebDir)" Condition="Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm install" WorkingDirectory="$(TodoWebDir)" Condition="!Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm run build" WorkingDirectory="$(TodoWebDir)" />
<Exec Command="npm run build:maui" WorkingDirectory="$(TodoWebDir)" />
</Target>
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoWebDistDir)\index.html')">
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And '$(UseDistSyncToWwwroot)' == 'true' And Exists('$(TodoWebDistDir)\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
@@ -169,15 +175,16 @@
</ItemGroup>
</Target>
<Target Name="CopyTodoWebDistToWindowsWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And Exists('$(TodoWebDistDir)')">
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(MSBuildProjectDirectory)\wwwroot\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
<_MauiWwwrootFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" />
</ItemGroup>
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
<MakeDir Directories="$(TargetDir)wwwroot" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<ItemGroup>
<MauiAsset Include="@(_MauiWwwrootFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
</Project>
@@ -189,3 +196,7 @@

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": true,
"IsUsingStatic": false,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
+2 -2
View File
@@ -1,8 +1,8 @@
#define MyAppName "Hua.Todo"
#define MyAppVersion "1.1.4"
#define MyAppVersion "1.1.8"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.exe"
#define MyAppExeName "Hua.Todo.Maui.exe"
[Setup]
; 1. 改用更快压缩(优先速度)
+2 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Hua.Todo-web",
"name": "Hua.Todo-Web",
"private": true,
"version": "0.0.0",
"license": "AGPL-3.0",
@@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build:maui": "vue-tsc -b && vite build --mode maui",
"preview": "vite preview"
},
"dependencies": {
+2
View File
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue';
import TaskList from './components/TaskList.vue';
import HotKeySettingsDialog from './components/HotKeySettingsDialog.vue';
import CloudSyncSettingsDialog from './components/CloudSyncSettingsDialog.vue';
const toastMessage = ref('');
const toastType = ref<'error' | 'success'>('error');
@@ -26,6 +27,7 @@ onMounted(() => {
<div class="app">
<TaskList />
<HotKeySettingsDialog />
<CloudSyncSettingsDialog />
<!-- Global Toast Notification -->
<Transition name="toast">
+69
View File
@@ -0,0 +1,69 @@
import axios from 'axios';
import CloudSyncStorage from '../services/cloudSyncStorage';
const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
/**
* 云同步专用 HTTP Client。
*
* 注意:本项目现有 `client.ts` 的 baseURL 固定为 `window.__API_BASE_URL__`(通常已包含 `/api`),
* 但云同步服务端端点位于根路径(`/auth/*`、`/tasks/*` 等)。
* 因此云同步需要独立的 baseURL(用户配置的服务端地址)。
*/
const cloudClient = axios.create({
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
cloudClient.interceptors.request.use((config) => {
const { serverUrl } = CloudSyncStorage.loadSettings();
if (serverUrl) {
config.baseURL = serverUrl;
}
const session = CloudSyncStorage.loadSession();
if (session?.accessToken) {
config.headers = config.headers ?? {};
(config.headers as any).Authorization = `Bearer ${session.accessToken}`;
}
return config;
});
cloudClient.interceptors.response.use(
(response) => response,
(error) => {
let errorMessage = '网络错误,请稍后再试';
if (error?.response?.data) {
const data = error.response.data;
if (typeof data === 'string' && data.trim()) {
errorMessage = data;
} else if (data && typeof data === 'object') {
const message = (data.message ?? data.Message) as unknown;
const errors = (data.errors ?? data.Errors) as unknown;
if (Array.isArray(errors) && errors.length > 0) {
errorMessage = errors.join('\n');
} else if (typeof message === 'string' && message.trim()) {
errorMessage = message;
}
}
} else if (typeof error?.message === 'string' && error.message.trim()) {
errorMessage = error.message;
}
showNotification(errorMessage, 'error');
return Promise.reject(error);
}
);
export default cloudClient;
+98
View File
@@ -0,0 +1,98 @@
import cloudClient from './cloudClient';
import CloudSyncStorage from '../services/cloudSyncStorage';
import type { Task } from '../types/task';
export interface CloudLoginRequest {
userName: string;
password: string;
}
export interface CloudLoginResponse {
accessToken: string;
expiresAtUtc: string;
userId: string;
role: string;
permissions: string[];
}
export interface CloudTaskItem {
id: number;
title: string;
priority: 0 | 1 | 2;
isCompleted: boolean;
createdAtUtc: string;
updatedAtUtc: string;
parentTaskId?: number | null;
}
/**
* 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。
*/
export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
const map = new Map<number, Task>();
for (const item of items) {
map.set(item.id, {
id: item.id,
title: item.title ?? '',
priority: item.priority ?? 1,
isCompleted: Boolean(item.isCompleted),
createdAt: item.createdAtUtc,
updatedAt: item.updatedAtUtc,
parentTaskId: item.parentTaskId ?? undefined,
subTasks: [],
});
}
const roots: Task[] = [];
for (const task of map.values()) {
const parentId = task.parentTaskId;
if (typeof parentId === 'number' && map.has(parentId)) {
map.get(parentId)!.subTasks.push(task);
} else {
roots.push(task);
}
}
return roots;
};
/**
* 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。
*/
export const cloudSyncApi = {
/**
* 登录云同步服务端并保存会话(默认存入 sessionStorage)。
*/
async login(request: CloudLoginRequest): Promise<CloudLoginResponse> {
const response = await cloudClient.post<CloudLoginResponse>('/auth/login', request);
const session = response.data;
CloudSyncStorage.saveSession({
accessToken: session.accessToken,
expiresAtUtc: session.expiresAtUtc,
userId: session.userId,
role: session.role,
permissions: Array.isArray(session.permissions) ? session.permissions : [],
});
return session;
},
/**
* 清空云同步会话(登出)。
*/
logout(): void {
CloudSyncStorage.clearSession();
},
/**
* 获取云端任务全量,并转换为前端树结构。
*/
async getTasks(): Promise<Task[]> {
const response = await cloudClient.get<CloudTaskItem[]>('/tasks/');
const items = Array.isArray(response.data) ? response.data : [];
return buildTaskTreeFromCloudItems(items);
},
};
export default cloudSyncApi;
@@ -0,0 +1,534 @@
<template>
<div v-if="isOpen" class="overlay" @click.self="close">
<div class="dialog">
<div class="dialog-header">
<h2> 云同步设置</h2>
<button class="close-button" @click="close">&times;</button>
</div>
<div class="dialog-body">
<div class="section">
<div class="section-title">同步开关</div>
<label class="checkbox-row">
<input type="checkbox" v-model="localEnabled" @change="onToggleEnabled" />
<span>启用云同步v1.2.0仅支持登录后拉取任务</span>
</label>
<div class="hint">
启用后主界面将以云端任务为准并进入只读展示本地编辑/新增将在后续版本补齐
</div>
</div>
<div class="section">
<div class="section-title">服务端地址</div>
<div class="form-row">
<input
v-model="serverUrlInput"
class="text-input"
type="text"
placeholder="例如:https://example.com"
autocomplete="off"
/>
<button class="btn btn-secondary" :disabled="isProbing" @click="saveServerUrl">
{{ isProbing ? '探测中...' : '保存并探测' }}
</button>
</div>
<div v-if="serverUrlSaved" class="hint">
已保存<span class="mono">{{ serverUrlSaved }}</span>
</div>
<div v-if="probeResult" class="probe" :class="probeResult.type">
<div class="probe-title">{{ probeResult.title }}</div>
<div class="probe-desc">{{ probeResult.desc }}</div>
</div>
</div>
<div class="section">
<div class="section-title">登录</div>
<div v-if="isLoggedIn" class="logged-in">
<div class="hint">
已登录<span class="mono">{{ sessionSummary }}</span>
</div>
<div class="button-row">
<button class="btn btn-secondary" @click="logout">登出</button>
<button class="btn btn-primary" :disabled="!localEnabled" @click="pullTasks">
拉取任务
</button>
</div>
</div>
<div v-else class="login-form">
<div class="form-row">
<input
v-model="userName"
class="text-input"
type="text"
placeholder="用户名"
autocomplete="username"
/>
<input
v-model="password"
class="text-input"
type="password"
placeholder="密码"
autocomplete="current-password"
/>
<button class="btn btn-primary" :disabled="!canLogin" @click="login">
{{ isLoggingIn ? '登录中...' : '登录' }}
</button>
</div>
<div class="hint">
说明服务端使用 Bearer 会话AccessToken=SessionId会话默认仅保存在本次运行sessionStorage
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="close">关闭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import CloudSyncStorage from '../services/cloudSyncStorage';
import cloudSyncApi from '../api/cloudSync';
type ProbeType = 'success' | 'warn' | 'error';
interface ProbeResult {
type: ProbeType;
title: string;
desc: string;
}
const isOpen = ref(false);
const localEnabled = ref(false);
const serverUrlInput = ref('');
const serverUrlSaved = ref('');
const isProbing = ref(false);
const probeResult = ref<ProbeResult | null>(null);
const userName = ref('');
const password = ref('');
const isLoggingIn = ref(false);
const loadLocalState = () => {
const settings = CloudSyncStorage.loadSettings();
localEnabled.value = settings.enabled;
serverUrlSaved.value = settings.serverUrl;
serverUrlInput.value = settings.serverUrl;
};
const isLoggedIn = computed(() => Boolean(CloudSyncStorage.loadSession()?.accessToken));
const sessionSummary = computed(() => {
const s = CloudSyncStorage.loadSession();
if (!s) return '';
const shortUserId = s.userId ? s.userId.slice(0, 8) : '';
return `${s.role || 'User'}@${shortUserId}`;
});
const canLogin = computed(() => {
if (!localEnabled.value) return false;
if (!serverUrlSaved.value) return false;
if (!userName.value.trim() || !password.value) return false;
return !isLoggingIn.value;
});
const showToast = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
const normalizeServerUrl = (raw: string): { ok: boolean; normalized?: string; message?: string; warn?: string } => {
const trimmed = raw.trim();
if (!trimmed) return { ok: false, message: '请输入服务端地址' };
let url: URL;
try {
url = new URL(trimmed);
} catch {
return { ok: false, message: '地址格式不正确,请包含协议(http:// 或 https://' };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { ok: false, message: '仅支持 http:// 或 https:// 地址' };
}
if (!url.hostname) {
return { ok: false, message: '地址缺少主机名(host' };
}
const origin = url.origin;
const pathWarn = url.pathname && url.pathname !== '/' ? '已忽略地址中的路径部分,仅保留 origin。' : undefined;
return { ok: true, normalized: origin.replace(/\/+$/, ''), warn: pathWarn };
};
const probeReachability = async (serverUrl: string): Promise<ProbeResult> => {
const u = new URL(serverUrl);
const isHttps = u.protocol === 'https:';
const loginUrl = `${serverUrl}/auth/login`;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(loginUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userName: '__probe__', password: '__probe__' }),
signal: controller.signal,
});
if (!isHttps) {
return {
type: 'warn',
title: '可达(存在风险)',
desc: '服务端可访问,但当前为 HTTP(非加密)。建议使用 HTTPS。',
};
}
return {
type: 'success',
title: '可达',
desc: `已探测到服务端响应(HTTP ${res.status})。可继续登录。`,
};
} catch {
if (!isHttps) {
return {
type: 'warn',
title: '存在风险',
desc: '当前为 HTTP 且探测失败。请检查地址是否可达,或改用 HTTPS。',
};
}
return {
type: 'warn',
title: '存在风险',
desc: '探测失败(可能是网络不可达 / 证书异常 / 跨域被阻止)。仍可保存地址并稍后重试登录。',
};
} finally {
window.clearTimeout(timeout);
}
};
const emitCloudSyncStateChanged = () => {
const settings = CloudSyncStorage.loadSettings();
const session = CloudSyncStorage.loadSession();
const event = new CustomEvent('cloudSyncStateChanged', {
detail: {
enabled: settings.enabled,
serverUrl: settings.serverUrl,
isLoggedIn: Boolean(session?.accessToken),
},
});
window.dispatchEvent(event);
};
const onToggleEnabled = () => {
if (localEnabled.value) {
if (!serverUrlSaved.value) {
localEnabled.value = false;
showToast('启用云同步前请先配置并保存服务端地址', 'error');
return;
}
}
CloudSyncStorage.saveSettings({
serverUrl: serverUrlSaved.value,
enabled: localEnabled.value,
});
emitCloudSyncStateChanged();
};
const saveServerUrl = async () => {
const parsed = normalizeServerUrl(serverUrlInput.value);
if (!parsed.ok || !parsed.normalized) {
showToast(parsed.message || '地址不合法', 'error');
return;
}
serverUrlSaved.value = parsed.normalized;
CloudSyncStorage.saveSettings({
serverUrl: serverUrlSaved.value,
enabled: localEnabled.value,
});
if (parsed.warn) {
showToast(parsed.warn, 'success');
} else {
showToast('地址已保存', 'success');
}
isProbing.value = true;
try {
probeResult.value = await probeReachability(serverUrlSaved.value);
} finally {
isProbing.value = false;
}
emitCloudSyncStateChanged();
};
const login = async () => {
if (!canLogin.value) return;
isLoggingIn.value = true;
try {
await cloudSyncApi.login({
userName: userName.value.trim(),
password: password.value,
});
showToast('登录成功', 'success');
emitCloudSyncStateChanged();
await pullTasks();
} finally {
isLoggingIn.value = false;
}
};
const logout = () => {
cloudSyncApi.logout();
showToast('已登出', 'success');
emitCloudSyncStateChanged();
};
const pullTasks = async () => {
const event = new CustomEvent('cloudSyncPullTasksRequested');
window.dispatchEvent(event);
};
function handleOpenSettings() {
loadLocalState();
probeResult.value = null;
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
onMounted(() => {
window.addEventListener('openCloudSyncSettings', handleOpenSettings);
});
</script>
<style scoped>
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 92%;
max-width: 720px;
overflow: hidden;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
.dialog-header h2 {
margin: 0;
font-size: 18px;
font-weight: 650;
color: #111827;
}
.close-button {
background: none;
border: none;
font-size: 28px;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: background 0.2s;
}
.close-button:hover {
background: #f3f4f6;
color: #111827;
}
.dialog-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
background: #ffffff;
}
.section-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: #6b7280;
text-transform: uppercase;
margin-bottom: 10px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
user-select: none;
}
.checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #3b82f6;
}
.hint {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #6b7280;
}
.mono {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.form-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.text-input {
flex: 1;
min-width: 240px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
transition: border-color 0.2s, box-shadow 0.2s;
}
.text-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.probe {
margin-top: 10px;
border-radius: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.probe.success {
border-color: rgba(34, 197, 94, 0.25);
background: rgba(34, 197, 94, 0.08);
}
.probe.warn {
border-color: rgba(245, 158, 11, 0.25);
background: rgba(245, 158, 11, 0.08);
}
.probe.error {
border-color: rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.08);
}
.probe-title {
font-size: 13px;
font-weight: 650;
color: #111827;
}
.probe-desc {
font-size: 12px;
color: #374151;
margin-top: 4px;
line-height: 1.4;
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 14px 20px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.btn {
padding: 10px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:enabled {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:enabled {
background: #f3f4f6;
border-color: #9ca3af;
}
</style>
+35 -3
View File
@@ -4,13 +4,14 @@
<input
type="checkbox"
:checked="task.isCompleted"
:disabled="readOnly"
@change="toggleComplete"
class="task-checkbox"
/>
<button v-if="task.subTasks && task.subTasks.length > 0" class="task-expand" @click.stop="toggleExpand" :title="isExpanded ? '收起' : '展开'">
<span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span>
</button>
<button @click="startAddSubTask" class="btn-add-subtask-icon" title="添加子任务">
<button v-if="!readOnly" @click="startAddSubTask" class="btn-add-subtask-icon" title="添加子任务">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
@@ -24,13 +25,13 @@
</div>
</div>
<div class="task-actions">
<button @click="openEditDialog" class="btn-edit-icon" title="编辑">
<button v-if="!readOnly" @click="openEditDialog" class="btn-edit-icon" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20h9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z" />
</svg>
</button>
<button @click="deleteTask" class="btn-delete-icon" title="删除">
<button v-if="!readOnly" @click="deleteTask" class="btn-delete-icon" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -70,6 +71,7 @@
v-for="subTask in task.subTasks"
:key="subTask.id"
:task="subTask"
:readOnly="readOnly"
@updated="handleSubTaskUpdated"
@deleted="handleSubTaskDeleted"
@subtask-created="$emit('subtask-created', $event)"
@@ -92,9 +94,11 @@ import type { Task } from '../types/task';
interface Props {
task: Task;
readOnly?: boolean;
}
const props = defineProps<Props>();
const readOnly = computed(() => props.readOnly === true);
const emit = defineEmits<{
(e: 'updated', task: Task): void;
@@ -110,6 +114,10 @@ const newSubTaskPriority = ref<0 | 1 | 2>(1);
const subTaskInput = ref<HTMLInputElement | null>(null);
const startAddSubTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
showAddSubTask.value = true;
await nextTick();
if (subTaskInput.value) {
@@ -152,6 +160,10 @@ const toggleExpand = () => {
};
const toggleComplete = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
try {
const response = await taskApi.toggleComplete(props.task.id);
if (response.success && response.data) {
@@ -163,6 +175,10 @@ const toggleComplete = async () => {
};
const deleteTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
try {
const response = await taskApi.deleteTask(props.task.id);
if (response.success) {
@@ -174,6 +190,10 @@ const deleteTask = async () => {
};
const createSubTask = async () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
if (!newSubTaskTitle.value.trim()) {
alert('请输入子任务标题');
return;
@@ -215,12 +235,24 @@ const handleSubTaskDeleted = (subTaskId: number) => {
};
const openEditDialog = () => {
if (readOnly.value) {
notifyReadOnly();
return;
}
showEditDialog.value = true;
};
const handleTaskSaved = (updatedTask: Task) => {
emit('updated', updatedTask);
};
const notifyReadOnly = () => {
if ((window as any).showToast) {
(window as any).showToast('云同步模式为只读展示,编辑/新增将在后续版本补齐', 'error');
} else {
window.alert('云同步模式为只读展示,编辑/新增将在后续版本补齐');
}
};
</script>
<style scoped>
+262 -11
View File
@@ -31,6 +31,29 @@
<path d="M11 20h2V9h3l-4-4-4 4h3v11z" />
</svg>
</button>
<div class="search-box">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="搜索任务..."
autocomplete="off"
@keydown="handleSearchKeydown"
/>
<button
v-if="searchQuery.trim().length > 0"
type="button"
class="search-clear-btn"
title="清空搜索"
@click="clearSearch"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<label class="checkbox-wrapper">
<input
type="checkbox"
@@ -45,6 +68,12 @@
</svg>
<span>快捷键</span>
</button>
<button class="cloud-sync-btn" @click="openCloudSyncSettings" title="云同步设置">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 18a4 4 0 1 1 1.1-7.85A6 6 0 0 1 20 12.5 3.5 3.5 0 0 1 18.5 19H7z" />
</svg>
<span>云同步</span>
</button>
</div>
</div>
<div class="task-info">
@@ -52,9 +81,12 @@
<span v-if="lastSyncTime > 0" class="info-item">
<span>更新{{ formatSyncTime(lastSyncTime) }}</span>
</span>
<span v-if="cloudStatusLabel" class="info-item">
<span>{{ cloudStatusLabel }}</span>
</span>
</div>
</div>
<div class="task-form">
<div v-if="!isReadOnly" class="task-form">
<input
v-model="newTaskTitle"
type="text"
@@ -74,8 +106,13 @@
</svg>
</button>
</div>
<div v-else class="cloud-readonly-banner">
云同步模式只读展示v1.2.0编辑/新增将在后续版本补齐
</div>
<div v-if="filteredTasks.length === 0" class="empty">暂无任务</div>
<div v-if="filteredTasks.length === 0" class="empty">
{{ searchQuery.trim().length > 0 ? '无匹配任务' : '暂无任务' }}
</div>
<div v-else class="tasks-shell">
<div class="tasks-scroll">
@@ -83,6 +120,7 @@
v-for="task in filteredTasks"
:key="task.id"
:task="task"
:readOnly="isReadOnly"
@updated="handleTaskUpdated"
@deleted="handleTaskDeleted"
@subtask-created="handleSubTaskCreated"
@@ -95,8 +133,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { taskApi } from '../api/tasks';
import cloudSyncApi from '../api/cloudSync';
import TaskItem from './TaskItem.vue';
import LocalStorageService from '../services/localStorageService';
import CloudSyncStorage from '../services/cloudSyncStorage';
import type { Task } from '../types/task';
type SortByType = 'createdAt' | 'completedAt' | 'priority';
@@ -111,6 +151,10 @@ const sortOrder = ref<SortOrderType>('desc');
const isOnline = ref(LocalStorageService.isOnline());
const lastSyncTime = ref<number>(0);
const pendingChanges = ref<number>(0);
const searchQuery = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null);
const isCloudSyncEnabled = ref(false);
const isCloudLoggedIn = ref(false);
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
@@ -139,17 +183,75 @@ const formatSyncTime = (timestamp: number): string => {
}
};
const clearSearch = () => {
searchQuery.value = '';
searchInputRef.value?.focus();
};
const handleSearchKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (searchQuery.value.trim().length > 0) {
event.preventDefault();
clearSearch();
}
return;
}
if (event.key === 'Enter') {
(event.target as HTMLInputElement | null)?.blur?.();
}
};
const normalizeSearchQuery = (query: string): string => query.trim().toLocaleLowerCase();
/**
* 搜索采用“命中即显示(含上下文)”策略:
* - 节点标题命中:保留该节点与其(已通过完成状态过滤后的)完整子树,便于理解层级上下文
* - 子任务命中:保留祖先链,但仅保留命中的分支,减少无关节点干扰
* - 英文大小写不敏感:通过 toLocaleLowerCase 归一化后做 includes 匹配
*/
const filterTasksBySearch = (tasks: Task[], normalizedQuery: string): Task[] => {
return tasks
.map(task => {
const title = (task.title ?? '').toLocaleLowerCase();
const isSelfMatched = title.includes(normalizedQuery);
const hasSubTasks = task.subTasks && task.subTasks.length > 0;
const filteredSubTasks = hasSubTasks ? filterTasksBySearch(task.subTasks, normalizedQuery) : [];
if (!isSelfMatched && filteredSubTasks.length === 0) {
return null;
}
return {
...task,
subTasks: isSelfMatched ? task.subTasks : filteredSubTasks
} satisfies Task;
})
.filter((task): task is Task => task !== null);
};
const filteredTasks = computed(() => {
const rootTasks = tasks.value.filter(t => !t.parentTaskId);
if (showCompleted.value) {
// 当显示已完成时,返回所有根任务(不进行过滤,保持原始树结构)
return sortTasks(rootTasks);
} else {
// 当隐藏已完成时,递归过滤掉已完成的任务
const activeTasks = filterTasksByCompletion(rootTasks, false);
return sortTasks(activeTasks);
}
const completionFilteredRootTasks = showCompleted.value
? rootTasks
: filterTasksByCompletion(rootTasks, false);
const normalizedQuery = normalizeSearchQuery(searchQuery.value);
const searchFilteredRootTasks = normalizedQuery.length > 0
? filterTasksBySearch(completionFilteredRootTasks, normalizedQuery)
: completionFilteredRootTasks;
return sortTasks(searchFilteredRootTasks);
});
const isReadOnly = computed(() => isCloudSyncEnabled.value && isCloudLoggedIn.value);
const cloudStatusLabel = computed(() => {
if (!isCloudSyncEnabled.value) return '';
if (!CloudSyncStorage.loadSettings().serverUrl) return '云同步:未配置';
if (!isCloudLoggedIn.value) return '云同步:未登录';
return '云同步:已登录';
});
const sortTasks = (tasks: Task[]): Task[] => {
@@ -227,6 +329,17 @@ const filterTasksByCompletion = (tasks: Task[], isCompleted: boolean): Task[] =>
const loadTasks = async () => {
try {
if (isReadOnly.value) {
const cloudTasks = await cloudSyncApi.getTasks();
tasks.value = cloudTasks;
lastSyncTime.value = Date.now();
const syncStatus = LocalStorageService.loadSyncStatus();
syncStatus.lastSyncTime = lastSyncTime.value;
LocalStorageService.saveSyncStatus(syncStatus);
pendingChanges.value = syncStatus.pendingChanges;
return;
}
const response = await taskApi.getTasks();
if (response.success && response.data) {
tasks.value = response.data;
@@ -334,16 +447,42 @@ const openHotKeySettings = () => {
window.dispatchEvent(event);
};
const openCloudSyncSettings = () => {
window.dispatchEvent(new CustomEvent('openCloudSyncSettings'));
};
const refreshCloudModeState = () => {
const settings = CloudSyncStorage.loadSettings();
const session = CloudSyncStorage.loadSession();
isCloudSyncEnabled.value = settings.enabled;
isCloudLoggedIn.value = Boolean(session?.accessToken);
};
const handleCloudSyncStateChanged = () => {
refreshCloudModeState();
loadTasks();
};
const handleCloudSyncPullTasksRequested = () => {
refreshCloudModeState();
loadTasks();
};
onMounted(() => {
refreshCloudModeState();
loadTasks();
handleOnlineStatus();
window.addEventListener('online', handleOnlineStatus);
window.addEventListener('offline', handleOnlineStatus);
window.addEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.addEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener);
});
onUnmounted(() => {
window.removeEventListener('online', handleOnlineStatus);
window.removeEventListener('offline', handleOnlineStatus);
window.removeEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.removeEventListener('cloudSyncPullTasksRequested', handleCloudSyncPullTasksRequested as EventListener);
});
</script>
@@ -583,6 +722,61 @@ onUnmounted(() => {
flex-wrap: wrap;
}
.search-box {
display: inline-flex;
align-items: center;
height: var(--header-chip-height);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
transition: all 0.3s ease;
overflow: hidden;
}
.search-box:focus-within {
border-color: #6366f1;
background: white;
}
.search-input {
height: 100%;
border: none;
outline: none;
background: transparent;
padding: 0 var(--header-chip-pad-x);
font-size: var(--header-chip-font-size);
color: #475569;
width: 160px;
min-width: 120px;
}
.search-input::placeholder {
color: #94a3b8;
}
.search-clear-btn {
height: 100%;
width: var(--header-chip-height);
padding: 0;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.search-clear-btn:hover {
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
}
.search-clear-btn:active {
transform: translateY(0.5px);
}
.sort-select {
height: var(--header-chip-height);
padding: 0 calc(var(--header-chip-pad-x) + 14px) 0 var(--header-chip-pad-x);
@@ -667,6 +861,53 @@ onUnmounted(() => {
font-size: inherit;
}
.cloud-sync-btn {
display: flex;
align-items: center;
gap: 4px;
height: var(--header-chip-height);
padding: 0 var(--header-chip-pad-x);
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: var(--header-chip-font-size);
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px -2px rgba(37, 99, 235, 0.35);
}
.cloud-sync-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px -2px rgba(37, 99, 235, 0.45);
}
.cloud-sync-btn:active {
transform: translateY(0);
}
.cloud-sync-btn svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.cloud-sync-btn span {
font-size: inherit;
}
.cloud-readonly-banner {
margin-bottom: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(14, 165, 233, 0.08);
border: 1px solid rgba(37, 99, 235, 0.18);
color: rgba(30, 64, 175, 0.92);
font-size: 13px;
font-weight: 550;
}
.empty {
text-align: center;
padding: 40px 20px;
@@ -767,6 +1008,11 @@ onUnmounted(() => {
justify-content: flex-start;
}
.search-input {
width: 130px;
min-width: 110px;
}
.sort-select {
height: var(--header-chip-height);
padding: 0 calc(var(--header-chip-pad-x) + 14px) 0 var(--header-chip-pad-x);
@@ -888,6 +1134,11 @@ onUnmounted(() => {
justify-content: flex-start;
}
.search-input {
width: 120px;
min-width: 100px;
}
.sort-select {
height: var(--header-chip-height);
padding: 0 calc(var(--header-chip-pad-x) + 14px) 0 var(--header-chip-pad-x);
@@ -0,0 +1,100 @@
export interface CloudSyncSettings {
serverUrl: string;
enabled: boolean;
}
export interface CloudSyncSession {
accessToken: string;
expiresAtUtc: string;
userId: string;
role: string;
permissions: string[];
}
const SETTINGS_KEY = 'Hua.Todo_cloud_sync_settings';
const SESSION_KEY = 'Hua.Todo_cloud_sync_session';
/**
* 读写云同步设置与会话信息。
*
* - 设置(serverUrl/enabled)需要跨重启保留,存 localStorage
* - 会话(accessToken 等)默认不持久化到磁盘,存 sessionStorage(关闭应用即失效)
*
* v1.2.0 仅实现“配置 + 登录 + 拉取任务”的最小闭环;更细的“可控落盘/内存模式”由 06-* 任务接管。
*/
export class CloudSyncStorage {
/**
* 读取云同步设置;不存在时返回默认值。
*/
static loadSettings(): CloudSyncSettings {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) {
return { serverUrl: '', enabled: false };
}
const parsed = JSON.parse(raw) as Partial<CloudSyncSettings>;
return {
serverUrl: typeof parsed.serverUrl === 'string' ? parsed.serverUrl : '',
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : false,
};
} catch {
return { serverUrl: '', enabled: false };
}
}
/**
* 保存云同步设置。
*/
static saveSettings(settings: CloudSyncSettings): void {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
/**
* 清空云同步设置(不会影响本地任务数据)。
*/
static clearSettings(): void {
localStorage.removeItem(SETTINGS_KEY);
}
/**
* 读取云同步会话;不存在时返回 null。
*/
static loadSession(): CloudSyncSession | null {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CloudSyncSession>;
if (!parsed.accessToken || typeof parsed.accessToken !== 'string') return null;
return {
accessToken: parsed.accessToken,
expiresAtUtc: typeof parsed.expiresAtUtc === 'string' ? parsed.expiresAtUtc : '',
userId: typeof parsed.userId === 'string' ? parsed.userId : '',
role: typeof parsed.role === 'string' ? parsed.role : '',
permissions: Array.isArray(parsed.permissions)
? parsed.permissions.filter((p): p is string => typeof p === 'string')
: [],
};
} catch {
return null;
}
}
/**
* 保存云同步会话到 sessionStorage。
*/
static saveSession(session: CloudSyncSession): void {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
/**
* 清空云同步会话(登出)。
*/
static clearSession(): void {
sessionStorage.removeItem(SESSION_KEY);
}
}
export default CloudSyncStorage;
+24 -17
View File
@@ -2,23 +2,30 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
outDir: resolve(__dirname, 'dist'),
emptyOutDir: true,
},
define: {
'window.__API_BASE_URL__': JSON.stringify('/api')
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
export default defineConfig(({ mode }) => {
// Use `vite build --mode maui` to emit artifacts into the MAUI app's `wwwroot` for embedded hosting.
const outDir = mode === 'maui'
? resolve(__dirname, '../Hua.Todo.Maui/wwwroot')
: resolve(__dirname, 'dist')
return {
plugins: [vue()],
build: {
outDir,
emptyOutDir: true,
},
define: {
'window.__API_BASE_URL__': JSON.stringify('/api')
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
},
},
},
}
})