chore: 完成v1.2版本迭代与代码清理

本次提交完成了多项清理与规范工作:
1. 移除默认管理员硬编码配置与云同步相关代码
2. 简化前端与MAUI端的配置,关闭静态资源托管以外的冗余功能
3. 清理.gitignore与协调目录,移除临时文件与冗余规则
4. 统一项目命名规范,修正包名与版本号
5. 重构后端数据模型,移除ABP审计字段与云同步相关逻辑
6. 简化WebView配置与系统栏样式,移除不必要的平台检测代码
7. 更新文档与规则文件,完善项目规范与版本记录
This commit is contained in:
ShaoHua
2026-06-15 22:06:58 +08:00
parent 46db04e43e
commit cf96c56bed
70 changed files with 2514 additions and 2092 deletions
-8
View File
@@ -366,11 +366,3 @@ FodyWeavers.xsd
/Hua.Todo/Output
/src/Hua.Todo.Maui/Output
/src/Hua.Todo.Host/Hua.Todo.db
/src/Hua.Todo.Host/Hua.Todo.db-shm
/src/Hua.Todo.Host/Hua.Todo.db-wal
/.artifacts/buildcheck
/.trae
/.artifacts
/.android-sdk
/src/Hua.Todo.Avalonia/wwwroot
/src/Hua.Todo.Avalonia/wwwroot
+18
View File
@@ -0,0 +1,18 @@
# 协调目录(并行 solo 专用)
该目录用于解决两类问题:
- 文件冲突:多窗口并行时明确"谁是 Writer",其他人不直接改同一文件
- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进
## 目录约定
- `01-ownership.md`:文件/目录所有权登记(Writer 表)
- `02-shared-files.md`:本阶段共享文件清单(由 Integrator 维护)
- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘)
- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件)
## 使用规则
- 所有权与共享文件清单优先使用"仓库相对路径"
- 禁止记录或提交构建产物目录中的文件路径(如 `bin/``obj/``node_modules/``dist/` 等)
+14
View File
@@ -0,0 +1,14 @@
# 文件/目录所有权(Writer)登记
规则:
- 同一时段内,同一个文件只能有一个 Writer
- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘
- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码
## 当前所有权
> 状态:空(无进行中的并行任务)。新增并行任务时按下方表头格式追加记录行;任务验收后由 Integrator 清空。
| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 |
|---|---|---|---|
+11
View File
@@ -0,0 +1,11 @@
# 本阶段共享文件清单(Integrator 维护)
规则:
- 本文件只由 Integrator 修改,避免反复冲突
- 清单内每条必须是"仓库相对路径",并说明为什么共享(入口/协议/配置/依赖锁等)
- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator
## 共享文件
> 状态:空(无进行中的并行任务)。Integrator 在新阶段开始时按"路径:为什么共享"格式追加;阶段结束后清空。
+28
View File
@@ -0,0 +1,28 @@
# 项目记忆(长期沉淀)
> 本文件保存**长期不变 / 不易频繁更新**的项目元信息。
> 当前实现进度、工单状态、未完结事项等"短期快照"请见 [.trae/rules/项目/04-即时状态记忆.md](../rules/项目/04-即时状态记忆.md)。
> 项目划分、依赖方向、运行模式见 [.trae/rules/项目/01-项目架构.md](../rules/项目/01-项目架构.md)。
## 关键依赖版本(含具体小版本号)
- **运行时**.NET 10.0SDK 10.0.201
- **C# 语言**C# 13
- **EF Core**10.0SQLite Provider
- **API 文档**Swashbuckle 10.1.7
- **前端**Vue 3 / TypeScript 5 / Vite 5Axios + Pinia
- **桌面**AvaloniaLinux/Windows/macOS
- **跨平台原生**MAUIAndroid/iOS/Windows/macOS
## 重要历史决策(不在即时状态记忆中重复记录)
- **.NET 10 适配**:针对 .NET 10 预览版特性(如 Windows TFM bug)进行规避与适配
- **Android 稳定性**:移除自动初始化 Provider,解决 AndroidX 相关崩溃
- **多平台构建开关**:通过 `Directory.Build.props` 优化非目标平台的构建依赖
- **跨平台热键平台拆分**Avalonia 与 MAUI 各自实现 `IGlobalHotKeyService`,平台目录分离
## 历史变更时间轴
- 2026-04-10:项目初始化与基础记忆建立
- 2026-04-13:升级至 v1.2.8,同步 CloudSync 与 DynamicApi 文档
- 2026-06:术语统一与目录中文化(详见 `04-即时状态记忆.md` § 五)
+33
View File
@@ -0,0 +1,33 @@
---
alwaysApply: true
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
---
# 注释规范(必须遵守)
## 通用
- 新增或修改的代码必须包含足够注释,使“不了解该模块的人”也能理解其职责、边界与关键决策。
- 优先使用 **XML 文档注释**`///`),而不是随意的行内注释。
- 不允许无意义注释(例如“初始化变量”“进入方法”)。注释必须解释“为什么/约束/边界/副作用”。
- 不允许出现“TODO/FIXME”但无上下文或无处理方案的注释。
## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途
- 对关键参数/返回值:`param` / `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
-**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
-**异步/后台任务**
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
-**安全/隐私**
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
## TypeScript / Vue(前端)
- 对导出的函数/类型必须有注释,解释用途与输入输出。
- 对“与后端/MAUI 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
+34
View File
@@ -0,0 +1,34 @@
---
alwaysApply: true
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
---
# 文档同步规范(必须遵守)
## 通用原则
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
- **协作友好(局部修改)**:当并行处理多个任务/需求时,更新文档应尽量只修改与本任务直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
## 更新范围
- **README.md**
- 如果变更涉及核心功能点(Features)、安装步骤(Installation)、快速开始(Quick Start)或 API 端点(API Endpoints),必须同步更新。
- 变更涉及技术栈调整或项目结构变化时需更新。
- **docs/ 目录文档**
- **接口变更**:若修改了 API,需同步更新 [技术设计文档](docs/技术设计文档.md) 中的接口部分。
- **功能新增/调整**:需在 [产品需求文档](docs/产品需求文档.md) 和 [技术栈与模块](docs/技术栈与模块.md) 中体现。
- **架构/模式变更**:需更新 [技术设计文档](docs/技术设计文档.md)。
- **代码规范**:若引入了新的编码模式或工具,需更新 [代码规范文档](docs/代码规范文档.md)。
- **版本记录**:所有非琐碎的变更必须在 [版本记录.md](docs/版本记录.md) 中添加记录。
## 检查清单
1. [ ] 是否有新增的 API 端点?(更新 README 和技术设计文档)
2. [ ] 是否修改了现有的业务逻辑或数据结构?(更新技术设计文档)
3. [ ] 是否有新增的功能模块?(更新产品需求文档和技术栈说明)
4. [ ] 是否调整了开发环境或依赖?(更新 README)
5. [ ] 是否在 [版本记录.md](docs/版本记录.md) 中记录了本次变更?
6. [ ] 文档变更是否保持“局部修改”,只影响与本任务相关的段落/小节?(避免无关重排/改写)
+53
View File
@@ -0,0 +1,53 @@
# 智能体记忆与规范同步规则(必须遵守)
> 适用范围:本规则属于 **全局规则**(语义层面跨项目可复用),关注智能体如何维护记忆与同步规范,与具体项目业务无关。
>
> `.trae/` 整体目录结构与各子目录职责见 [.trae/索引.md](../../索引.md),本文件不再重复描述。
## 记忆存储
- 智能体的记忆必须存放在 `.trae/memory` 文件夹中
- 记忆应按对话日期或主题进行组织,便于后续查询和参考
- 记忆内容应包含对话历史、关键决策、重要代码片段和规范调整等信息
- **项目即时状态**(当前实现到哪一步、未完结事项、临时决策快照)应同步写入 `.trae/rules/项目/04-即时状态记忆.md`,便于其他智能体或开发者快速对齐
## 规范同步
- 每次对话中涉及到的语法或规范相关内容,必须同步整理到 `.trae/rules` 目录下的对应文件中
- **通用规范**(注释、文档同步、工单流程、并行冲突)→ 写入 `.trae/rules/全局/`
- **项目专属**(业务命名、架构边界、数据模型、即时状态)→ 写入 `.trae/rules/项目/`
- 若涉及到新的规范或规则,应创建新的规则文件进行记录
- 规范同步应及时、准确,确保规则文件能真实反映当前项目的编码规范和最佳实践
## 文件命名规则(强制)
`.trae/` 下所有子目录中**新增的文件必须沿用 `NN-名称.md` 序号格式**,否则视为不合规:
- **格式**:两位数字 + 连字符 + 中文/英文名称 + `.md`,例如 `08-XXX规范.md`
- **序号取值**:紧接当前目录已有最大序号 +1,不得跳号、不得重复
- **入口/索引文件例外**`.trae/索引.md` 这类目录入口文件不带序号
- **重排禁止**:除非整体重构,否则不得重排已有文件的序号;新增只能追加在末尾
- **同步更新索引**:每次新增文件后,必须在 [.trae/索引.md](../../索引.md) 的对应章节同步追加该文件的链接与一句话职责说明
- **跨目录创建**:在 `coordination/``memory/` 下新增文件时同样适用本规则
### 各子目录当前最大序号速查
| 子目录 | 当前最大序号 | 下一个可用 |
|---|---|---|
| `rules/全局/` | 07 | 08 |
| `rules/项目/` | 04 | 05 |
| `memory/` | 01 | 02 |
| `coordination/` | 02 | 03 |
## 实现要求
- 智能体应定期检查并更新规则文件,确保其与项目实际情况保持一致
- 当发现规范冲突或需要调整时,应及时记录并通知相关人员
- 记忆存储和规范同步应作为智能体的核心功能,贯穿于整个开发过程
## 路径规范
- 所有 Markdown 文档中不应使用绝对路径,应使用相对路径
- 相对路径应以项目根目录为基准,例如 `.trae/memory` 而非绝对路径
- 确保路径格式统一,使用正斜杠 (`/`) 作为路径分隔符,避免使用反斜杠 (`\`)
- 智能体在生成或修改文档时,应自动检查并替换绝对路径为相对路径
@@ -0,0 +1,33 @@
# 记忆存储规范
> 适用范围:本规则属于 **全局规则**(跨项目通用),约束 `.trae/memory/` 的使用方式。
## 存储结构
- 记忆文件应存放在 `.trae/memory` 文件夹中
- 避免使用与日期相关的文件名,使用通用的描述性文件名
- 文件命名采用 `NN-名称.md` 序号格式
- 记忆内容应包含对话历史、关键决策、重要代码片段和规范调整等信息
## 内容规范
- 记忆文件应保持简洁明了,重点记录重要的开发决策和规范变更
- 避免存储冗余信息,只记录对项目有价值的内容
- 定期清理过时的记忆文件,保持存储空间的合理使用
## 与"项目即时状态"的边界
- `.trae/memory/` 偏向**长期保留**的对话产物与决策记录
- `.trae/rules/项目/04-即时状态记忆.md` 偏向**当前快照**(实现进度、未完结事项),更新频率高
- 二者不要重复存放同一份信息;以"是否需要长期沉淀"为判定标准
## memory/ 生命周期
研发工单验收完成后,对 `memory/` 的处理遵循以下原则:
- **追加而非覆盖**:将本次工单中产生的、值得**长期沉淀**的内容(架构决策、关键避坑经验、引入的新依赖与版本)追加到对应文件
- **不存放过程信息**:实现进度、待办勾选、临时决策这些短期信息应留在 [04-即时状态记忆.md](../项目/04-即时状态记忆.md),不进 `memory/`
- **不删长期内容**:除非内容已被证伪或过时,否则不删除既有条目;过时内容用"已废弃 / 已被 XX 取代"的形式保留语义而非物理删除
- **新增文件序号化**:当主题足够独立时新建 `NN-名称.md`(序号紧接当前最大值),并在 [.trae/索引.md](../../索引.md) 的 `memory/` 章节同步追加链接
## 访问权限
- 记忆文件仅供开发团队内部参考使用
- 确保记忆文件中的敏感信息得到适当保护
- 遵循项目的版本控制和代码管理规范
+34
View File
@@ -0,0 +1,34 @@
---
alwaysApply: true
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
---
# 注释规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用),针对 C# / TypeScript / Vue 代码的注释要求。
## 通用
- 新增或修改的代码必须包含足够注释,使"不了解该模块的人"也能理解其职责、边界与关键决策。
- 优先使用 **XML 文档注释**`///`),而不是随意的行内注释。
- 不允许无意义注释(例如"初始化变量""进入方法")。注释必须解释"为什么/约束/边界/副作用"。
- 不允许出现"TODO/FIXME"但无上下文或无处理方案的注释。
## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途
- 对关键参数/返回值:`param` / `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
-**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。
-**异步/后台任务**
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
-**安全/隐私**
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
## TypeScript / Vue(前端)
- 对导出的函数/类型必须有注释,解释用途与输入输出。
- 对"与后端/MAUI 交互"的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
@@ -0,0 +1,38 @@
---
alwaysApply: true
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
---
# 文档同步规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
## 通用原则
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
- **协作友好(局部修改)**:当并行处理多个研发工单/需求时,更新文档应尽量只修改与本工单直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
> 术语澄清:本规范中"研发工单"指编码工作项;项目业务里的"任务/Todo 待办项"是用户域实体,二者不要混淆。详见 [05-研发工单规则.md](./05-研发工单规则.md)。
## 更新范围
- **README.md**
- 如果变更涉及核心功能点(Features)、安装步骤(Installation)、快速开始(Quick Start)或 API 端点(API Endpoints),必须同步更新。
- 变更涉及技术栈调整或项目结构变化时需更新。
- **docs/ 目录文档**
- **接口变更**:若修改了 API,需同步更新 [技术设计文档](docs/技术设计文档.md) 中的接口部分。
- **功能新增/调整**:需在 [产品需求文档](docs/产品需求文档.md) 和 [技术栈与模块](docs/技术栈与模块.md) 中体现。
- **架构/模式变更**:需更新 [技术设计文档](docs/技术设计文档.md)。
- **代码规范**:若引入了新的编码模式或工具,需更新 [代码规范文档](docs/代码规范文档.md)。
- **版本记录**:所有非琐碎的变更必须在 [版本记录.md](docs/版本记录.md) 中添加记录。
## 检查清单
1. [ ] 是否有新增的 API 端点?(更新 README 和技术设计文档)
2. [ ] 是否修改了现有的业务逻辑或数据结构?(更新技术设计文档)
3. [ ] 是否有新增的功能模块?(更新产品需求文档和技术栈说明)
4. [ ] 是否调整了开发环境或依赖?(更新 README)
5. [ ] 是否在 [版本记录.md](docs/版本记录.md) 中记录了本次变更?
6. [ ] 文档变更是否保持"局部修改",只影响与本工单相关的段落/小节?(避免无关重排/改写)
+129
View File
@@ -0,0 +1,129 @@
# 研发工单同步规则汇总(Dev Work Item Rules
> 适用范围:本规则属于 **全局规则**(跨项目通用),关注智能体如何拆分编码工作。
> ⚠️ 术语澄清(必读)
>
> 本项目存在两类"任务"概念,必须严格区分,避免命名混淆:
>
> | 术语 | 含义 | 适用范围 |
> |---|---|---|
> | **研发工单(Dev Work Item** | 智能体 / 开发者执行的**编码工作项**(拆分需求、并行开发、集成等) | 本规则文档的全部内容 |
> | **Todo 待办项(Todo Item** | Hua.Todo 项目**业务领域**中用户创建的待办事项(数据库实体、API 资源、UI 列表项) | 业务代码、产品需求文档、技术设计文档 |
>
> 本文档中所有"研发工单 / 工单 / 子工单"均指**编码工作项**,与业务侧的 Todo 待办项无关。
> 在代码、文档与对话中,凡涉及编码侧拆分时,**必须使用"研发工单"或"工单"**,禁止再使用"任务"二字以避免与 Todo 待办项混淆。
>
> 业务侧由于历史原因仍保留 `Task` / `SubTask` 等代码标识符(API、实体、UI),这些属于 Todo 待办项语义,**不在本规范替换范围内**。
>
> 本汇总文件是 [06-研发工单拆分规范.md](./06-研发工单拆分规范.md) 与 [07-并行窗口冲突规约.md](./07-并行窗口冲突规约.md) 的对外索引,详细规则以这两份源文件为准。
---
## 一、研发工单拆分规范
### 适用时机
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出**研发工单拆分文档**
### 输出要求
1. **先读完所有相关文档**:包括 `docs/``docs/project/` 下与本次需求相关的内容
2. **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据
3. **新增专属文件夹**:在 `docs/project` 下新建 `研发工单-<主题>-<日期或版本>` 文件夹
4. **可并行工单拆分**:能同步执行的工单必须拆到不同 Markdown 文件中
5. **文件带序号**:按执行顺序编号(`01-xxx.md``02-xxx.md`
### 每个研发工单文件必须包含
- 目标 / 范围(做什么、不做什么)
- 前置条件(依赖哪些结论 / 接口 / 文档)
- 验收标准(可执行的验证点)
- 风险与回滚(如有)
### 子工单完成标记要求
- 子工单完成后,必须在 `00-工单总览.md` 中标注"已完成"
- 维护"待验证表",记录每个子工单的"待验证 / 已验证"状态
---
## 二、并行窗口冲突规约
### 核心原则
1. **先声明后修改**:修改前先声明 Touch List 与共享文件策略
2. **文件所有权唯一**:同一时段内一个文件只能由一个窗口修改
3. **共享文件单点修改**:高耦合 / 共享入口的改动集中到集成窗口完成
4. **绿线优先**:任何可落盘的变更必须保持可编译
### Touch List 要求
- 精确到文件路径
- 标注修改类型(新增 / 小改 / 重构 / 接口变更 / 配置变更)
- 标注是否为共享文件
- 使用相对路径:`src\<module>\<file>`
### 共享文件判定标准(满足其一即为共享)
- 项目入口 / 启动逻辑、依赖注入注册、全局路由
- 公共配置、公共协议与 DTO、公共组件 / 样式
- 解决方案文件(`.sln``.csproj`)、锁文件、全局配置
### Writer 约束
- 非 Writer 窗口不得编辑共享文件
- 非 Writer 只能提供"差异建议"给 Writer 落盘
### 协调目录
固定目录:`.trae\coordination\`
- `01-ownership.md`:文件所有权登记表
- `02-shared-files.md`:共享文件清单(集成窗口维护)
- `handoff\`:差异建议 / 交接说明
- `wip\`:编译中途状态说明
### 编译绿线规则
- 不得提交破坏编译的变更
- 临时隔离手段(按优先级):
1. 新功能先放在新文件中,不在入口路径启用
2. 通过显式开关控制,默认关闭
3. 通过依赖注入分支或特性开关隔离
- 接口演进采用"双写 / 兼容期"策略
---
## 三、推荐文档结构
```
docs/project/研发工单-<主题>-<版本>/
├── 00-工单总览.md # 背景、目标、关键决策、并行分组、待验证表
├── 01-并行工单A.md
├── 02-并行工单B.md
└── 03-串行工单C.md
```
> 注意:上述目录与文件名中的"工单"指**研发工单**,与业务侧 Todo 待办项无关。
---
## 四、检查清单
### 研发工单拆分检查
1. [ ] 是否已阅读完所有相关文档?
2. [ ] 是否在 `docs/project` 下新建了专属文件夹(命名以"研发工单-"开头)?
3. [ ] 是否产出 `00-工单总览.md`
4. [ ] 是否将可并行工单拆分为不同 md 文件?
5. [ ] 是否所有 md 文件都带有连续序号?
6. [ ] 子工单完成后是否在总览中标注"已完成"并更新待验证表?
### 并行冲突检查
1. [ ] 每个研发工单 md 是否已写 Touch List(精确到文件)?
2. [ ] Touch List 中的共享文件是否指定了唯一 Writer?
3. [ ] 是否避免了对共享文件的无意义格式化 / 重排?
4. [ ] 当前改动是否保持可编译(绿线)?
5. [ ] 若涉及接口演进,是否采用兼容期策略?
---
## 五、与业务侧 Todo 待办项的边界
- 代码、注释、提交信息中描述**编码工作**时:使用"研发工单 / 工单 / 子工单"
- 代码、注释、提交信息中描述**业务功能**时:使用"Todo 待办项 / Todo Item / 父子任务(业务实体)"
- 文档命名前缀:
- 编码侧:`研发工单-<主题>-<版本>/`
- 业务侧(如有):遵循 `docs/` 既有命名习惯,禁止使用"研发工单"前缀
- 提交信息示例:
-`feat(todo): 新增 Todo 待办项截止日期字段(研发工单 02-后端模型)`
-`feat: 完成任务 02`"任务"歧义,禁用)
@@ -0,0 +1,57 @@
---
alwaysApply: false
---
# 研发工单拆分输出规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
> 详见 [05-研发工单规则.md](./05-研发工单规则.md)。
> 凡涉及编码侧拆分时,**必须使用「研发工单」或「工单」**,禁止使用「任务」二字以避免与 Todo 待办项混淆。
## 适用时机
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出研发工单拆分文档,再开始写代码或改配置。
## 输出要求
- **先读完所有相关文档**:包括但不限于 `docs/``docs/project/` 下与本次需求相关的内容。
- **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据。
- **新增一个专属文件夹**:在 `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` 标注"已完成",并在"待验证表"里更新状态?
@@ -0,0 +1,115 @@
---
alwaysApply: false
description:
---
# 并行 solo 窗口冲突规约(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
> 详见 [05-研发工单规则.md](./05-研发工单规则.md)。
> 凡涉及编码侧拆分时,**必须使用「研发工单」或「工单」**,禁止使用「任务」二字以避免与 Todo 待办项混淆。
## 适用范围
- 当同一个版本/需求被拆分为多个并行研发工单,并由多个 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\01-ownership.md`:文件/目录所有权(Writer)登记表
- `.trae\coordination\02-shared-files.md`:本阶段共享文件清单(只有 Integrator 维护)
- `.trae\coordination\handoff\`:非 Writer 提交的差异建议/交接说明(Integrator 落盘)
- `.trae\coordination\wip\`:编译中途状态说明(为什么需要隔离、如何保证绿线、何时收敛)
## 目录分区与低冲突写法
- 优先通过"新增文件"完成并行开发,减少在同一文件内的交错修改。
- 需要扩展既有逻辑时,优先选择低冲突策略:
- C#:新增类/partial 文件、扩展方法、接口实现分文件、平台目录分离
- TypeScript/Vue:新增模块/组件文件,避免在同一大文件内做多处改动
- 禁止在非必要情况下对共享文件做纯格式化、纯重排或无收益重构(这些改动高度易冲突且难以 review)。
## 编译绿线(避免编译区间冲突)
- **不得提交/合入破坏编译的变更**:包括缺失类型、未实现接口、引用不存在、配置缺项导致启动失败等。
- **允许的临时隔离手段(按优先级)**:
1. 新功能先放在新文件/新类中,不在入口路径上启用
2. 通过显式开关控制启用(配置/运行时开关),默认关闭
3. 通过依赖注入分支注册或特性开关隔离,默认不触发
- 当必须进行接口演进时,采用"双写/兼容期"策略:
- 先新增(保持旧接口可用)→ 再迁移调用方 → 最后清理旧接口
## 合入顺序与集成职责
- 每个并行阶段必须明确一个集成窗口(Integrator),负责:
- 处理共享文件的实际落盘与冲突消解
- 保持主干/集成分支持续可编译、可运行
- 其他窗口提交的成果应尽量以"新增文件 + 最小修改点"的方式交付,降低集成成本。
## 任务验收后 coordination 清理
研发工单验收完成、并行阶段结束后,**Integrator 必须**对 `.trae/coordination/` 做收尾清理:
- **清空记录行**:将 `01-ownership.md``02-shared-files.md` 中的运行时记录行删除,仅保留文件顶部说明、表头与示例占位行(让下一轮并行可以直接复用)
- **归档 handoff/wip**:删除已落盘消化掉的 `handoff/``wip/` 内容;如有需要长期沉淀的关键决策,迁移到 [.trae/memory/](../../memory) 对应文件中
- **不删除文件本身**`00-README.md``01-ownership.md``02-shared-files.md` 三个常驻文件保留,仅清空内容
- **冲突收尾确认**:清理前确保所有共享文件已合入主干、Touch List 已不再被任何窗口引用
- **同步项目状态**:在 [.trae/rules/项目/04-即时状态记忆.md](../项目/04-即时状态记忆.md) 的"工单状态快照"中将相关工单标记为已验证
## 最小检查清单
1. [ ] 每个研发工单 md 是否已写 Touch List(精确到文件)?
2. [ ] Touch List 中的共享文件是否指定了唯一 Writer?
3. [ ] 是否避免了对共享文件的无意义格式化/重排?
4. [ ] 当前改动是否保持可编译(绿线)?
5. [ ] 若涉及接口演进,是否采用兼容期策略而非一次性破坏式变更?
+75
View File
@@ -0,0 +1,75 @@
# 项目架构(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 描述当前仓库的项目划分、依赖方向、运行模式与跨平台策略,供智能体在做改动时快速对齐架构边界。
> 完整设计参见 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md) 与 [docs/manual/03-技术栈与模块.md](../../../docs/manual/03-技术栈与模块.md)。
## 一、项目清单(src/
| 项目 | 类型 | 职责 | 平台 |
|---|---|---|---|
| [Hua.Todo.Core](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Core) | 类库 | 领域实体、仓储接口(`TaskEntity``UserEntity``SecurityPolicyEntity``AuditLogEntity``ITaskRepository` 等) | netstandard / net |
| [Hua.Todo.Application](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application) | 类库(共享层) | EF Core `TodoDbContext``TaskService`/`TaskRepository`、动态 API`DynamicApi/`)、云同步(`CloudSync/`)、迁移 | net |
| [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host) | ASP.NET 服务端 | 独立服务端宿主,承载本地动态 API + 云同步端点 + Admin 管理后台静态资源 | Windows/Linux 等服务器 |
| [Hua.Todo.Maui](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Maui) | MAUI 客户端 | Windows / macOS / iOS / Android 入口;内嵌 WebServer + WebView 承载 Vue 前端 | 多端 |
| [Hua.Todo.Avalonia](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Avalonia) | Avalonia 客户端 | Linux / Windows 桌面入口;内嵌 WebServer + `WebView.Avalonia` 承载 Vue 前端 | 桌面(含 Linux |
| [Hua.Todo.Web](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Web) | Vite + Vue 3 前端 | 同一份前端构建产物,被多个宿主以静态资源形式承载 | 浏览器/WebView |
## 二、依赖方向(必须保持单向)
```
Hua.Todo.Core
Hua.Todo.Application
├── Hua.Todo.Host (服务端注册 AddCloudSyncServer + 动态 API)
├── Hua.Todo.Maui (客户端,仅注册 AddApplicationServices)
└── Hua.Todo.Avalonia (客户端,仅注册 AddApplicationServices)
Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主)
```
- **禁止反向依赖**Core 不得引用 ApplicationApplication 不得引用任何宿主项目。
- **客户端不暴露云同步端点**MAUI / Avalonia 只调用 `AddApplicationServices()`,不调用 `AddCloudSyncServer()`,详见 [docs/project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md](../../../docs/project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md)。
## 三、两种运行模式
### 模式 A:嵌入式(MAUI / Avalonia + WebView
- 启动内嵌 Kestrel WebServer(默认端口 5057),托管 Vue 前端的静态产物(`wwwroot/`+ 本地 `/api/*`
- WebView 加载 `HostUrl`(生产)或 `ForEndUrl`(开发,例如 Vite dev server
- 注入:`window.__API_BASE_URL__ = "${HostUrl}/api"``window.mauiInterop`
- 默认 SQLite 路径:`LocalApplicationData/Hua.Todo/Hua.Todo.db`(避免安装目录无写权限)
- 不暴露云同步端点;登录/同步走外部 Host
### 模式 B:独立服务端(Hua.Todo.Host
- 独立部署的 ASP.NET 应用,端口 `:5173`Vite proxy 目标)
- 同时注册 `AddApplicationServices()` + `AddCloudSyncServer()`,对外提供:
- `/api/*`:本地任务 API(动态 API
- `/auth/*``/tasks/*``/sync``/security/policy``/cloud-sync/probe`:云同步端点
- `/admin/*`:管理后台前端静态资源
- 数据库:`src/Hua.Todo.Host/Hua.Todo.db`(开发/测试用)
## 四、跨平台原则
- **业务逻辑统一在 Application 层**,宿主项目只做 DI 装配与平台桥接
- **平台差异通过接口 + 平台目录**实现(例:`IGlobalHotKeyService` 在 MAUI 与 Avalonia 各有自己的 `Platforms/` 子目录实现)
- **前端只有一份**:Vue 项目通过不同的 `.env.*` 文件区分模式(`.env.development` / `.env.maui` / `.env.production`
## 五、关键扩展点
| 扩展点 | 位置 |
|---|---|
| DI 注册总入口 | [Hua.Todo.Application/ServiceCollectionExtensions.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/ServiceCollectionExtensions.cs) |
| 云同步 DI | `Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs` |
| 云同步端点 | `Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs` |
| 动态 API 中间件 | `Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs` |
| 嵌入式 WebServer | `Hua.Todo.{Maui,Avalonia}/Services/EmbeddedWebServerService.cs` |
| 全局快捷键平台实现 | `Hua.Todo.{Maui,Avalonia}/Services/Platforms/*GlobalHotKeyService.cs` |
## 六、版本统一策略
- 全局版本号通过根目录 `Directory.Build.props` 集中管理(当前 `1.2.3`
- Inno Setup 安装包:`src/Hua.Todo.Maui/setup.iss``src/Hua.Todo.Avalonia/setup.iss`
- Linux`publish-linux.ps1` 产出 `.tar.gz``pack/linux/` 含 Flatpak 基础结构
- 详见 [docs/project/研发工单-v1.2.0/02.1-版本统一与打包方案.md](../../../docs/project/研发工单-v1.2.0/02.1-版本统一与打包方案.md)
@@ -0,0 +1,60 @@
# 业务命名规范(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 本文档规定 **业务实体**(Todo 待办项相关)与 **编码工作项**(研发工单)在代码、文档、提交信息中的命名边界。
> 全局术语规则参见 [.trae/rules/全局/05-研发工单规则.md](../全局/05-研发工单规则.md)。
## 一、术语对照(核心)
| 概念 | 含义 | 代码标识符 | 文档用语 |
|---|---|---|---|
| **Todo 待办项 / 业务任务** | 用户在 Hua.Todo 中创建的待办事项 | `Task` / `SubTask` / `TaskEntity` / `TaskItem` / `TaskService` / `TaskRepository` | "Todo 待办项 / 任务 / 子任务(业务实体)" |
| **研发工单(Dev Work Item** | 智能体/开发者执行的编码工作项 | 无(仅文档层概念) | "研发工单 / 工单 / 子工单" |
## 二、代码命名约定(已固化,不要改名)
> 以下标识符已落入 API 契约、数据库表、前端类型,**禁止以"统一术语"为由进行重命名**。
### 2.1 .NET / C# 侧
- 实体:`TaskEntity`(位于 [Hua.Todo.Core/Entities/TaskEntity.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Core/Entities/TaskEntity.cs)
- 字段:`ParentTaskId`(父子任务关系)、`Priority``UserId`
- 服务:`ITaskService` / `TaskService``ITaskRepository` / `TaskRepository`
- 云同步 DTO`CloudTaskItem`(位于 `Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs`
- 用户固定 ID`TodoUserIds.LocalUserId = "local"`(见 `Hua.Todo.Core/Entities/TodoUserIds.cs`
### 2.2 前端(Vue/TS)侧
- 类型:`TaskItem` / `TaskNode`(位于 [Hua.Todo.Web/src/types/task.ts](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Web/src/types/task.ts)
- 组件:`TaskList.vue` / `TaskItem.vue` / `TaskEditDialog.vue`
- API 模块:`api/tasks.ts``api/cloudSync.ts`
### 2.3 HTTP API 路由
- 本地动态 API`/api/task``/api/task/{parentTaskId}/subtasks`(由 `Hua.Todo.Application/DynamicApi` 自动暴露)
- 云端:`GET /tasks``POST /sync``POST /cloud-sync/probe`
- 见 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md)、[docs/project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md](../../../docs/project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md)
### 2.4 数据库表
- `Tasks``Users``UserSessions``SecurityPolicies``AuditLogs`
- 迁移文件位于 `Hua.Todo.Application/Migrations/`
## 三、新增代码命名指引
### 3.1 与 Todo 业务相关的新代码
- 沿用 `Task` / `SubTask` 词根,与既有命名保持一致
- 如:`TaskFilterService``SubTaskCounter``TaskExportDto`
### 3.2 与研发工单/工程基础设施相关的新代码
- 不要使用 `Task` 词根(避免与业务实体撞名)
- 如果是 .NET 异步方法,可使用 `Async` 后缀但不要把方法/类型主体命名为 `Task`
### 3.3 文档与提交信息
- 业务变更:`feat(todo): 新增 Todo 待办项截止日期字段`
- 工程变更:`chore(workitem): 拆分 v1.2.0 研发工单 02-后端模型`
- 严禁含糊措辞:`feat: 完成任务 02`"任务"歧义)
## 四、命名一致性检查清单
1. [ ] 新增类/接口是否复用了已有 `Task*` 命名习惯?
2. [ ] HTTP 路由是否与既有 `/api/task` / `/tasks` 风格一致?
3. [ ] 文档段落是否在首次出现"任务"时明确指向 Todo 待办项还是研发工单?
4. [ ] 提交信息是否避免了歧义"任务"用法?
@@ -0,0 +1,99 @@
# 数据模型与迁移约束(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 描述当前数据库实体、字段约束、EF Core 迁移历史,约束智能体在改 schema 时遵循的纪律。
## 一、技术栈
- ORM**EF Core**
- 数据库:**SQLite**(嵌入式与 Host 开发都走 SQLite;生产 Host 可切其他后端)
- DbContext[TodoDbContext](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/Data/TodoDbContext.cs)
- 迁移目录:`Hua.Todo.Application/Migrations/`
- **表名规范**:遵循 ABP 模板规范,格式为 `T_{实体名}s`,如 `T_Tasks``T_Users`
## 二、当前实体清单(src/Hua.Todo.Core/Entities/
### 2.1 TaskEntity(待重构为 ABP 基类)
> ⚠️ **重大变更预警**TaskEntity 计划继承 `FullAuditedEntityWithUser<Guid, IdentityUser>`,重构后将包含 ABP 全部审计字段。此改为破坏性变更,详见 [09-CloudSync-同步策略改进方案.md](../../../docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md)。
**重构前(当前)**
| 字段 | 类型 | 说明 |
|---|---|---|
| `Id` | `int` | 主键(待改为 Guid |
| `UserId` | `Guid` | 任务所属用户 |
| `Title` | `string` | 标题 |
| `Priority` | `TaskPriority` | 优先级枚举 |
| `IsCompleted` | `bool` | 是否完成 |
| `CreatedAt` | `DateTime` | 创建时间 |
| `UpdatedAt` | `DateTime` | 更新时间 |
| `ParentTaskId` | `int?` | 父任务ID(待改为 Guid? |
**重构后(ABP 标准)**
继承 `FullAuditedEntityWithUser<Guid, IdentityUser>` 后自动获得:
| ABP 审计字段 | 类型 | 说明 |
|---|---|---|
| `Id` | `Guid` | 主键(强制 Guid |
| `ExtraProperties` | `ExtraPropertyDictionary` | 扩展属性(基类提供) |
| `ConcurrencyStamp` | `string?` | 并发戳 |
| `CreationTime` | `DateTime` | 创建时间 |
| `CreatorId` | `Guid?` | 创建人 |
| `LastModificationTime` | `DateTime?` | 最后修改时间 |
| `LastModifierId` | `Guid?` | 最后修改人 |
| `IsDeleted` | `bool` | 软删除标记 |
| `DeletionTime` | `DateTime?` | 删除时间 |
| `DeleterId` | `Guid?` | 删除人 |
业务字段保留:`UserId``Title``Priority``IsCompleted``ParentTaskId`
### 2.2 其他实体
| 实体 | 关键字段 | 约束 |
|---|---|---|
| `TaskPriority` | 枚举 | Priority 字段对应类型 |
| `UserEntity` | `Id``UserName``PasswordHash``PasswordSalt``Role``MustChangePassword` | 唯一索引:`UserName` |
| `UserSessionEntity` | `SessionId``UserId``ExpiresAtUtc``IsStepUp``StepUpExpiresAtUtc` | session token 由 DB 管理(非纯 JWT |
| `SecurityPolicyEntity` | `UserId``AllowPersist``AllowSync``SecondFactorExpiryMinutes``IsTrustedDeviceOnly` | 与 User 一对一 |
| `AuditLogEntity` | `Id``UserId``Action``OccurredAtUtc``Details` | 关键安全事件 |
| `TodoUserIds` | `LocalUserId = "local"` | 静态常量类,非实体 |
## 三、迁移历史(按时间)
| 迁移 | 含义 |
|---|---|
| `20260313044926_InitialCreate` | 初始 Tasks 表 |
| `20260313092658_AddParentTaskId` | 父子任务字段 |
| `20260406172936_AddCloudSyncCoreEntities` | Users / UserSessions / SecurityPolicies |
| `20260406173734_AddAllowSyncToSecurityPolicy` | `AllowSync` 字段 |
| `20260413140347_UpdateSecurityEntities` | 安全实体调整 |
| `20260413140753_AddAuditLogs` | AuditLogs 表 |
| `20260424164713_AddPasswordSaltToUsers` | 密码加盐 |
| `20260510171230_AddMustChangePasswordToUsers` | 强制改密标志 |
| `MakeTaskEntityAbpCompatible`(待创建) | 重构为继承 ABP 基类,新增审计字段,主键从 `int` 改为 `Guid` |
## 四、改 schema 必须遵守的纪律
1. **禁止手改 `*ModelSnapshot.cs`**:使用 `dotnet ef migrations add` 命令生成
2. **迁移命名以动词开头**`AddXxx` / `UpdateXxx` / `RemoveXxx` / `RenameXxx`
3. **避免破坏性迁移**:删除字段前先确认数据已被业务层迁移;优先采用"双写/兼容期"
4. **嵌入式宿主启动时自动 Migrate**:见 `EmbeddedWebServerService.cs` 中的 `db.Database.Migrate()` 调用;任何破坏迁移幂等性的改动都会让客户端启动失败
5. **业务字段命名沿用 `Task*` 词根**(即业务实体),编码工作侧不要在实体上用 `Task` 词根之外的同义词
6. **`UserId` 字段不可为空**:本地模式使用 `TodoUserIds.LocalUserId = "local"`,云端模式使用真实用户 GUID 字符串
## 五、DTO 与实体的映射边界
- **实体(Entity**:仅在 `Hua.Todo.Core` / `Hua.Todo.Application/Data` 内部使用
- **DTOModels**:跨进程边界(HTTP API、WebView 注入)使用,位于 `Hua.Todo.Application/CloudSync/Models/``Hua.Todo.Application/Models/`
- **不要把实体直接序列化为 API 响应**:避免暴露内部字段、避免循环引用
## 六、检查清单(schema 变更)
1. [ ] 是否新增了 EF Core 迁移(而非手改快照)?
2. [ ] 迁移名称是否以 `AddXxx`/`UpdateXxx`/`RemoveXxx` 开头?
3. [ ] 是否在 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md) 中同步更新数据模型描述?
4. [ ] 是否在 [docs/manual/06-版本记录.md](../../../docs/manual/06-版本记录.md) 中追加非琐碎变更条目?
5. [ ] 是否在嵌入式宿主上验证了启动时 Migrate 不报错?
@@ -0,0 +1,62 @@
# 即时状态记忆(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 本文档保存"当前实现到哪一步、未完结事项、临时决策"等**短期快照**,用于跨会话/跨智能体快速对齐。
> 与 [.trae/memory/](../../memory) 的差别:memory 偏长期沉淀,此处偏即时状态,更新频率高。
>
> **维护要求**:智能体每完成一个研发工单或观察到状态变化时,必须更新本文件的对应章节。
---
## 一、当前活跃版本
- **进行中版本**v1.2.0
- **研发工单总览**[docs/project/研发工单-v1.2.0/00-工单总览.md](../../../docs/project/研发工单-v1.2.0/00-工单总览.md)
- **PRD**[docs/project/产品需求文档-1.2.0.md](../../../docs/project/产品需求文档-1.2.0.md)
## 二、v1.2.0 工单状态快照
| 子工单 | 实现状态 | 验证状态 | 简要说明 |
|---|---|---|---|
| 01 - Linux Avalonia 入口 + WebView | 已完成 | 待验证 | `WebView.Avalonia` + `WebView.Avalonia.Desktop`Linux 依赖 GTK + WebKitGTK |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `publish-linux.ps1``.tar.gz`Flatpak 基础结构在 `pack/linux/` |
| 02.1 - 版本统一打包 | 已落地 | 待验证 | `Directory.Build.props` 统一版本;Avalonia 新增 `setup.iss` |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 主界面搜索框,按 Todo 标题包含匹配;Esc 清空;英文不分大小写 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约固化;`Tasks` 表加 `UserId` 隔离;RBAC + step-up |
| 05 - 云同步 客户端配置/工作流 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址保存探测、登录/登出、登录后只读展示云端 Todo |
| 06 - 安全与可控落盘 | 未标注 | 待验证 | 框架就绪(`SecurityPolicy`),客户端落盘策略尚未充分覆盖 |
| 06.1 - 服务端安全设计 | 已设计 | 待实现 | Argon2id/JWT/审计日志/Admin 管理后台规划 |
| 07 - 文档同步与验收 | 进行中 | 进行中 | README/docs 已基本对齐;术语对照刚完成 |
| 08 - cloud_sync 重构 | 已设计 | 待实现 | "同源 Host"方案,Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` |
| 09 - CloudSync 同步策略改进 | 已实现 | 待验证 | TaskEntity 继承 ABP 基类;软删除修复(SaveChangesAsync);前端类型和 cloudSync.ts 已更新;新增 guid.ts |
## 三、关键临时决策
- **MAUI 端不暴露云同步端点**:`MauiProgram.cs` 仅注册 `AddApplicationServices()`,不调 `AddCloudSyncServer()`。云同步端点只在 `Hua.Todo.Host` 暴露。
- **本地用户 ID 固定为 `"local"`**:嵌入式模式下 `Tasks.UserId = TodoUserIds.LocalUserId`,与云端用户隔离逻辑共存而不冲突。
- **SQLite WAL 模式**:嵌入式宿主启动时强制开启 WAL,降低锁冲突。
- **数据库路径**:默认 `LocalApplicationData/Hua.Todo/Hua.Todo.db`(避免安装目录无写权限);Host 模式使用 `src/Hua.Todo.Host/Hua.Todo.db`(开发/测试)。
## 四、已知未完结事项 / 待办
- [ ] 06 客户端"内存模式"在 `allowPersist=false` 时的端到端落盘清理(含 token、同步队列)尚未充分验证
- [ ] 06.1 设计中的 Admin 管理后台前端(位于 `Hua.Todo.Host/wwwroot/admin/`)当前仅有 `index.html` 占位,需 Vue 3 + Vite 实现
- [ ] 08 同源 Host 改造:`cloudClient.ts` 的 baseURL 解耦、Vite proxy 端点补全
- [ ] Linux Flatpak/AppImage 自包含产物在干净环境的实测验证(v1.2.0 验收 Linux 部分仍为"待验证"
- [x] CloudSync UNIQUE 约束修复(2026-06-14):修复了 `existingTasks` 查询在事务外导致并发重同步时 `T_Tasks.Id` UNIQUE 约束冲突;新增 7 个测试(含 5 个 SQLite 集成测试)
## 五、最近一次重大重构(如有)
- **术语统一与目录中文化**(2026-06):
- `.trae/rules/` 全部中文文件名 + 拆分为 `全局/``项目/` 两个子目录
- `docs/project/v1.2.0-tasks/``docs/project/研发工单-v1.2.0/``00-任务总览.md``00-工单总览.md`
- 在 PRD 顶部加入"研发工单 vs Todo 待办项"术语对照表
- 业务代码 `Task`/`SubTask` 标识符**保持不动**(已固化于 API、DB、UI)
- **`.trae` 子目录文件序号化**(2026-06):
- `.trae/rules/全局/``.trae/rules/项目/``.trae/coordination/``.trae/memory/` 下所有文件加 `NN-` 序号前缀
- `.trae/索引.md` 不带序号(入口文件)
---
> **更新约定**:每次智能体修改本文件时,必须更新顶部的"进行中版本"和"工单状态快照"两节,确保信息不过时。
+74
View File
@@ -0,0 +1,74 @@
# .trae 目录索引
> 本文件是 `.trae/` 目录的总入口,描述各子目录与关键文件的职责,便于智能体与开发者快速定位。
>
> 关于 TRAE 官方"全局规则 vs 项目规则"TRAE IDE 的"全局规则"由设置中心保存到用户级、**不入仓**;本仓库 `.trae/rules/` 下的所有内容(含子目录)都会被 TRAE 识别为**项目规则**。下面的子目录 `全局/` 与 `项目/` 是项目内部的**语义分组**,不改变 TRAE 的加载语义。
>
> 子目录嵌套深度受 TRAE 官方限制:`.trae/rules/` 下最多 3 层嵌套。
>
> 文件命名约定:每个子目录内文件以 `NN-名称.md` 格式编号(两位数字),编号反映**阅读优先级 / 依赖顺序**。索引文件本身不带序号。
>
> ⚠️ **新增文件必须遵守命名规则**:序号 = 当前目录最大值 + 1,不得跳号或重复,并需同步更新本索引文件中的对应章节。详见 [01-智能体记忆.md](./rules/全局/01-智能体记忆.md#文件命名规则强制)。
>
> ⚠️ **任务完成后清理义务**:研发工单验收完成后,必须清理 `coordination/` 中的临时记录行(保留文件与表头),并按需追加 `memory/` 的长期记忆条目。详见 [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md#任务验收后-coordination-清理) 与 [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md#memory-生命周期)。
---
## 整体结构
```
.trae/
├── 索引.md ← 本文件(目录入口)
├── rules/
│ ├── 全局/ ← 通用规范(跨项目可复用的方法论)
│ └── 项目/ ← Hua.Todo 专属(业务/架构/状态/数据)
├── memory/ ← 长期记忆数据(决策、对话产物)
└── coordination/ ← 多 solo 窗口协作目录(运行时登记表)
```
---
## rules/全局/ — 通用规范(与具体项目无关)
| 文件 | 职责 |
|---|---|
| [01-智能体记忆.md](./rules/全局/01-智能体记忆.md) | 智能体记忆/规范同步机制(**不含目录总览,由本索引文件承担**) |
| [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md) | `.trae/memory/` 的使用方式 |
| [03-注释规范.md](./rules/全局/03-注释规范.md) | C# / TypeScript / Vue 代码注释要求 |
| [04-文档同步规范.md](./rules/全局/04-文档同步规范.md) | 代码变更同步 README/docs 的硬性要求 |
| [05-研发工单规则.md](./rules/全局/05-研发工单规则.md) | 研发工单术语与边界(编码工作项 vs Todo 待办项) |
| [06-研发工单拆分规范.md](./rules/全局/06-研发工单拆分规范.md) | 工单拆分目录与文件结构 |
| [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 |
---
## rules/项目/ — Hua.Todo 专属
| 文件 | 职责 |
|---|---|
| [01-项目架构.md](./rules/项目/01-项目架构.md) | src/ 五大项目划分、依赖方向、两种运行模式 |
| [02-业务命名规范.md](./rules/项目/02-业务命名规范.md) | `Task`/`SubTask`/`TaskEntity` 等代码标识符与"研发工单"边界 |
| [03-数据模型与迁移约束.md](./rules/项目/03-数据模型与迁移约束.md) | EF Core 实体清单、迁移历史、改 schema 纪律 |
| [04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) | 当前活跃版本 / 工单状态快照 / 临时决策 / 未完结事项 |
---
## memory/ — 长期记忆
- 用途:长期沉淀的对话产物、关键开发决策、不再频繁更新的项目快照
- 与"项目即时状态"的边界:[04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) 偏当前快照、更新频率高;`memory/` 偏长期保留
- 维护规约:详见 [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md)
- 文件:
- [01-project_memory.md](./memory/01-project_memory.md):依赖小版本号、历史决策、变更时间轴
---
## coordination/ — 多窗口协作运行时目录
- 用途:并行 solo 窗口下的"谁在改什么"登记表与共享文件清单(**运行时数据,非冷文档**)
- 协作协议详见 [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md)
- 文件:
- [00-README.md](./coordination/00-README.md):目录约定
- [01-ownership.md](./coordination/01-ownership.md):文件/目录所有权(Writer)登记
- [02-shared-files.md](./coordination/02-shared-files.md):本阶段共享文件清单(Integrator 维护)
- `handoff/``wip/`:差异建议交接 / 编译中途状态(按需新建)
+4 -3
View File
@@ -6,12 +6,13 @@
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<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.Application/Hua.Todo.Application.csproj">
<Build Solution="Debug|*" Project="true" />
</Project>
<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">
<Deploy Solution="Debug|Any CPU" />
<Build Solution="Debug|*" Project="false" />
</Project>
</Folder>
</Solution>
+32 -121
View File
@@ -1,33 +1,30 @@
# Hua.Todo 跨平台代办管理应用 v1.2.8
# Hua.Todo 跨平台代办管理应用
一个基于 **WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer** 架构开发的跨平台代办管理应用,支持 **Windows、macOS、Android、iOS 和 Linux** 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
一个基于 MAUI + WebView 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI + Avalonia + WebView 架构,覆盖 Windows、macOS、Android、iOS 和 Linux 五大平台
- **任务管理**:支持任务创建、编辑、删除、完成状态切换,以及子任务管理
- **任务排序**:支持按创建时间、完成时间、优先级排序,可切换升序/降序
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取/推送云端任务,支持安全策略配置(内存模式、同步开关、二次认证、受信任设备限制
- **用户管理与权限**:支持多用户管理、RBAC 细粒度权限控制(6 个权限点)、管理员密码加盐存储、首次登录强制修改密码
- **审计日志**:记录所有安全事件(登录/注销/密码修改/引导等),支持管理员查询
- **会话管理**Bearer Token 认证,支持二次认证(step-up)提升权限,Token 即时失效
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感
- **本地数据持久化**:使用 SQLite 数据库保存数据(WAL 模式优化),支持 DateTime 兼容性解析
- **离线模式**:支持完全离线使用,数据优先保存于本地;安全策略禁止时可切换为内存模式不落盘
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试
- **全局快捷键**:支持系统级快捷键快速唤起应用,修饰键与主键均可配置
- **系统托盘**:支持最小化到系统托盘,关闭窗口隐藏到托盘,托盘菜单快捷操作
- **跨平台支持**:基于 MAUI + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
## 📦 安装与使用
### 环境要求
- **后端**.NET 10 SDK + Visual Studio 2022 或更高版本
- **前端**Node.js 18+ + npm 或 yarn
- **后端**
- .NET 10 SDK
- Visual Studio 2022 或更高版本
- **前端**
- Node.js 18+
- npm 或 yarn
### 快速开始
#### 1. 克隆项目
#### 1. 克隆或下载项目
```bash
git clone <仓库地址>
cd Hua.Todo
@@ -40,7 +37,6 @@ dotnet restore
dotnet run
```
API 将在 `http://localhost:5173` 启动
开发环境下提供 Swagger UI`http://localhost:5173/swagger`
#### 3. 启动前端 Web
```bash
@@ -48,38 +44,10 @@ cd src/Hua.Todo.Web
npm install
npm run dev
```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到后端
#### 4. 启动 MAUI 客户端(Windows 三件套开发)
推荐使用脚本一键启动:
```powershell
.\start-dev.ps1
```
### 构建与发布
#### Windows 交付产物
```powershell
# 生成 Windows 安装包(Inno Setup
.\publish-windows.ps1
```
输出:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`
#### Linux 交付产物
```powershell
# 生成 Linux 发布包
.\publish-linux.ps1
```
或使用统一发布脚本:
```powershell
# 默认发布 Windows + Linux
.\publish.ps1
# 仅发布 Windows
.\publish.ps1 -Windows
```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
### 使用说明
- **添加任务**:在前端界面输入任务内容,设置优先级,点击添加按钮
- **添加任务**:在前端界面输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
- **完成任务**:点击任务前的复选框切换完成状态
- **删除任务**:点击删除按钮移除任务
@@ -89,88 +57,33 @@ npm run dev
### 项目结构
```
Hua.Todo/
├── pack/ # 打包与交付产物(Linux/安装包等)
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ └── project/ # 项目进度/需求文档
├── src/ # 源代码目录
│ ├── Hua.Todo.Core/ # 领域实体与基础接口
│ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现
│ │ ├── CloudSync/ # 云同步模块
│ │ ├── Data/ # 数据访问层
│ │ └── DynamicApi/ # 动态 API 生成
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
│ ├── Hua.Todo.Maui/ # 跨平台客户端 (Windows/Android/iOS/macOS)
│ └── Hua.Todo.Avalonia/ # 桌面客户端 (Windows/macOS/Linux)
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档
```
### 技术栈
| 分类 | 技术 |
|------|------|
| 后端语言 | C# 13 (.NET 10) |
| UI 框架 | MAUI + Avalonia |
| 服务器 | Kestrel (ASP.NET Core) |
| 数据访问 | Entity Framework Core 10 |
| 数据库 | SQLite |
| 前端框架 | Vue.js 3 + TypeScript |
| 构建工具 | Vite 5+ |
| HTTP 客户端 | Axios |
### API 端点
#### 任务管理
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | `/api/task` | 获取任务列表(默认:全部) |
| GET | `/api/task/active` | 获取未完成任务 |
| GET | `/api/task/completed` | 获取已完成任务 |
| GET | `/api/task/{id}` | 获取单个任务 |
| POST | `/api/task` | 创建任务 |
| PUT | `/api/task` | 更新任务(通过 Body 内的 id 定位) |
| PATCH | `/api/task/{id}/toggle` | 切换完成状态 |
| DELETE | `/api/task/{id}` | 删除任务 |
| GET | `/api/task/{parentTaskId}/subtasks` | 获取子任务列表 |
#### 云同步(Host 模式)
| 方法 | 端点 | 说明 |
|------|------|------|
| POST | `/auth/bootstrap` | 初始化管理员(仅首次) |
| POST | `/auth/login` | 用户登录 |
| POST | `/auth/logout` | 用户注销 |
| POST | `/auth/step-up` | 二次验证(提升权限) |
| POST | `/auth/change-password` | 修改密码 |
| GET | `/tasks/` | 获取云端任务(只读) |
| POST | `/sync/` | 推送/拉取合并同步 |
| GET | `/security/policy` | 获取安全策略 |
| PUT | `/security/policy` | 更新安全策略 |
| POST | `/cloud-sync/probe` | 服务端可达性探测 |
| GET | `/admin/users` | 获取用户列表 |
| POST | `/admin/users` | 创建用户 |
| POST | `/admin/users/{userId}/reset-password` | 重置用户密码 |
| DELETE | `/admin/users/{userId}` | 删除用户 |
| GET | `/admin/sessions` | 获取活跃会话列表 |
| DELETE | `/admin/sessions/{sessionId}` | 强制终止会话 |
| GET | `/admin/audit-logs` | 获取审计日志 |
| GET | `/admin` | 管理后台入口 |
## 📊 模块说明
- **Hua.Todo.Core**:领域实体层,定义核心实体、枚举及仓储接口
- **Hua.Todo.Application**:应用层,包含业务逻辑、动态 API 生成、云同步服务
- **Hua.Todo.Host**:后端 API 宿主,提供独立运行时环境
- **Hua.Todo.Web**:前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite
- **Hua.Todo.Maui**:跨平台客户端,支持 Windows、Android、iOS 和 macOS
- **Hua.Todo.Avalonia**:桌面客户端,专注 Linux 平台支持,同时兼容 Windows/macOS
- `GET /api/tasks` - 获取任务列表
- `GET /api/tasks/{id}` - 获取单个任务
- `POST /api/tasks` - 创建任务
- `PUT /api/tasks/{id}` - 更新任务
- `PATCH /api/tasks/{id}/complete` - 切换完成状态
- `DELETE /api/tasks/{id}` - 删除任务
## 🤝 交流与贡献
- **QQ 交流群**2167048911 (Hua.Todo 交流群)
- **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
- **贡献指南**:欢迎提交 Pull Request
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md)
## 📄 开源协议
@@ -179,18 +92,16 @@ Hua.Todo/
## 📚 更多文档
### 用户与开发者手册
- [技术栈与模块说明](docs/manual/03-技术栈与模块.md)
- [版本更新历史](docs/manual/06-版本记录.md)
- [技术设计文档](docs/manual/01-技术设计文档.md)
- [代码规范文档](docs/manual/04-代码规范文档.md)
- [部署文档](docs/manual/05-部署文档.md)
- [其他信息](docs/manual/07-其他信息.md)
- [技术栈与模块说明](docs/manual/技术栈与模块.md)
- [版本更新历史](docs/manual/版本记录.md)
- [技术设计文档](docs/manual/技术设计文档.md)
- [代码规范文档](docs/manual/代码规范文档.md)
- [其他信息 (贡献、许可证、联系方式)](docs/manual/其他信息.md)
### 项目进度与需求
- [产品需求文档](docs/project/产品需求文档.md)
- [v1.2.0 研发工单总览](docs/project/研发工单-v1.2.0/00-工单总览.md)
- [Android 离线排查计划](docs/project/Android_NotFound_排查计划.md)
- [实现对比文档](docs/project/实现对比文档.md)
---
**Hua.Todo** - 跨平台任务管理,让效率无处不在!
+675
View File
@@ -0,0 +1,675 @@
# Hua.Todo 代码规范文档 v1.1.0
## 1. 概述
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
## 2. 通用规范
### 2.1 命名约定
- **使用有意义的名称**: 变量、函数、类名应清晰表达其用途
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
- **一致性**: 在整个项目中保持命名风格一致
### 2.2 注释规范
- **公共 API 必须添加 XML 文档注释**
- **复杂逻辑添加行内注释**
- **避免注释显而易见的代码**
- **保持注释与代码同步更新**
### 2.3 代码格式化
- **使用统一的代码格式化工具**
- **保持一致的缩进和空格**
- **每行代码不超过 120 字符**
- **文件末尾保留一个空行**
## 3. C# 代码规范
### 3.1 命名规范
#### 类和接口
```csharp
// 类名使用 PascalCase
public class TaskService
{
}
// 接口名使用 PascalCase,以 I 开头
public interface ITaskService
{
}
```
#### 方法和属性
```csharp
// 方法名使用 PascalCase
public Task<List<Task>> GetTasksAsync()
{
}
// 属性名使用 PascalCase
public string Title { get; set; }
```
#### 变量和参数
```csharp
// 私有字段使用 _camelCase
private readonly ITaskRepository _taskRepository;
// 局部变量使用 camelCase
var taskList = await GetTasksAsync();
// 方法参数使用 camelCase
public void CreateTask(string title, TaskPriority priority)
{
}
```
#### 常量
```csharp
// 常量使用 PascalCase
public const int MaxTaskTitleLength = 200;
```
### 3.2 代码组织
#### 文件结构
```csharp
// 1. using 语句(按字母顺序)
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
// 2. 命名空间
namespace Hua.Todo.Api.Services;
// 3. XML 文档注释
/// <summary>
/// 任务服务实现
/// </summary>
public class TaskService : ITaskService
{
// 4. 私有字段
private readonly ITaskRepository _taskRepository;
// 5. 构造函数
public TaskService(ITaskRepository taskRepository)
{
_taskRepository = taskRepository;
}
// 6. 公共方法
public async Task<List<Task>> GetTasksAsync()
{
// 实现
}
// 7. 私有方法
private bool ValidateTask(Task task)
{
// 实现
}
}
```
#### 命名空间组织
- 每个文件只包含一个命名空间
- 命名空间结构应与目录结构一致
- 使用 `.` 分隔层级
### 3.3 编码规范
#### 异步编程
```csharp
// 异步方法应以 Async 结尾
public async Task<Task> GetTaskByIdAsync(int id)
{
return await _taskRepository.GetByIdAsync(id);
}
// 使用 await 而非 .Result 或 .Wait
var task = await GetTaskByIdAsync(id);
// 使用 ConfigureAwait(false) 在库代码中
public async Task<List<Task>> GetTasksAsync()
{
return await _taskRepository.GetAllAsync().ConfigureAwait(false);
}
```
#### 依赖注入
```csharp
// 优先使用构造函数注入
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
private readonly ILogger<TaskService> _logger;
public TaskService(ITaskRepository taskRepository, ILogger<TaskService> logger)
{
_taskRepository = taskRepository;
_logger = logger;
}
}
```
#### 异常处理
```csharp
// 使用具体的异常类型
public async Task<Task> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
{
throw new NotFoundException($"Task with id {id} not found");
}
return task;
}
// 使用 using 语句管理资源
using var context = new TodoDbContext();
```
#### LINQ 使用
```csharp
// 优先使用方法语法
var completedTasks = tasks.Where(t => t.IsCompleted).ToList();
// 复杂查询使用查询语法
var query = from task in tasks
where task.IsCompleted
orderby task.CreatedAt descending
select task;
```
### 3.4 文档注释
```csharp
/// <summary>
/// 获取指定 ID 的任务
/// </summary>
/// <param name="id">任务 ID</param>
/// <returns>任务对象</returns>
/// <exception cref="NotFoundException">当任务不存在时抛出</exception>
public async Task<Task> GetTaskByIdAsync(int id)
{
// 实现
}
```
## 4. JavaScript/TypeScript 代码规范
### 4.1 命名规范
#### 变量和函数
```typescript
// 变量使用 camelCase
const taskList = [];
let currentTask = null;
// 函数使用 camelCase
function getTasks() {
// 实现
}
// 常量使用 UPPER_SNAKE_CASE
const MAX_TASK_TITLE_LENGTH = 200;
```
#### 类和接口
```typescript
// 类名使用 PascalCase
class TaskService {
// 实现
}
// 接口名使用 PascalCase
interface Task {
id: number;
title: string;
}
// 类型别名使用 PascalCase
type TaskPriority = 'high' | 'medium' | 'low';
```
### 4.2 代码组织
#### 文件结构
```typescript
// 1. 导入语句
import { ref, computed } from 'vue';
import { useTaskStore } from '@/stores/tasks';
import type { Task } from '@/types/task';
// 2. 类型定义
interface TaskForm {
title: string;
priority: TaskPriority;
}
// 3. 常量定义
const DEFAULT_PRIORITY: TaskPriority = 'medium';
// 4. 组合式函数或组件
export function useTasks() {
// 实现
}
```
#### 模块导入
```typescript
// 优先使用 ES6 模块语法
import { ref } from 'vue';
import axios from 'axios';
// 导出使用具名导出
export function useTasks() {
// 实现
}
export default useTasks;
```
### 4.3 TypeScript 规范
#### 类型定义
```typescript
// 为所有函数参数和返回值添加类型
function getTaskById(id: number): Task | null {
// 实现
}
// 使用接口定义对象类型
interface Task {
id: number;
title: string;
priority: TaskPriority;
isCompleted: boolean;
createdAt: Date;
}
// 使用类型别名定义联合类型
type TaskPriority = 'high' | 'medium' | 'low';
// 使用泛型提高代码复用性
interface ApiResponse<T> {
data: T;
message: string;
}
```
#### 类型断言
```typescript
// 优先使用类型守卫而非类型断言
function isTask(obj: unknown): obj is Task {
return typeof obj === 'object' && obj !== null && 'id' in obj;
}
// 避免使用 as any
const task = response.data as Task; // 避免
```
### 4.4 异步编程
```typescript
// 使用 async/await 而非 Promise 链
async function getTasks(): Promise<Task[]> {
const response = await axios.get('/api/tasks');
return response.data;
}
// 错误处理
try {
const tasks = await getTasks();
} catch (error) {
console.error('Failed to fetch tasks:', error);
}
```
## 5. Vue.js 代码规范
### 5.1 组件命名
```vue
<!-- 组件名使用 PascalCase -->
<script setup lang="ts">
// 组件名应与文件名一致
</script>
<template>
<!-- 模板中使用 kebab-case -->
<task-item :task="task" />
</template>
```
### 5.2 组件结构
```vue
<template>
<!-- 1. 模板 -->
<div class="task-list">
<task-item
v-for="task in tasks"
:key="task.id"
:task="task"
/>
</div>
</template>
<script setup lang="ts">
// 2. 导入
import { ref, computed } from 'vue';
import { useTaskStore } from '@/stores/tasks';
import TaskItem from './TaskItem.vue';
// 3. Props 定义
interface Props {
filter: 'all' | 'active' | 'completed';
}
const props = withDefaults(defineProps<Props>(), {
filter: 'all'
});
// 4. Emits 定义
const emit = defineEmits<{
(e: 'task-created', task: Task): void;
}>();
// 5. 响应式状态
const taskStore = useTaskStore();
const tasks = computed(() => taskStore.filteredTasks(props.filter));
// 6. 方法
const handleCreateTask = async (title: string) => {
const task = await taskStore.createTask(title);
emit('task-created', task);
};
</script>
<style scoped>
/* 6. 样式 */
.task-list {
padding: 16px;
}
</style>
```
### 5.3 组合式函数规范
```typescript
// composables/useTasks.ts
import { ref, computed } from 'vue';
import { useTaskStore } from '@/stores/tasks';
export function useTasks() {
const taskStore = useTaskStore();
const loading = ref(false);
const error = ref<string | null>(null);
const tasks = computed(() => taskStore.tasks);
const completedTasks = computed(() => taskStore.completedTasks);
const fetchTasks = async () => {
loading.value = true;
error.value = null;
try {
await taskStore.fetchTasks();
} catch (err) {
error.value = 'Failed to fetch tasks';
console.error(err);
} finally {
loading.value = false;
}
};
return {
tasks,
completedTasks,
loading,
error,
fetchTasks
};
}
```
### 5.4 状态管理规范
```typescript
// stores/tasks.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Task } from '@/types/task';
export const useTaskStore = defineStore('tasks', () => {
// State
const tasks = ref<Task[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// Getters
const activeTasks = computed(() =>
tasks.value.filter(task => !task.isCompleted)
);
const completedTasks = computed(() =>
tasks.value.filter(task => task.isCompleted)
);
// Actions
async function fetchTasks() {
loading.value = true;
try {
const response = await fetch('/api/tasks');
tasks.value = await response.json();
} catch (err) {
error.value = 'Failed to fetch tasks';
} finally {
loading.value = false;
}
}
return {
tasks,
loading,
error,
activeTasks,
completedTasks,
fetchTasks
};
});
```
## 6. API 设计规范
### 6.1 RESTful API 设计
```csharp
// 使用名词复数形式
[HttpGet("tasks")]
public async Task<ActionResult<List<Task>>> GetTasks()
{
}
// 使用资源 ID
[HttpGet("tasks/{id}")]
public async Task<ActionResult<Task>> GetTask(int id)
{
}
// 使用 HTTP 方法表示操作
[HttpPost("tasks")]
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
{
}
[HttpPut("tasks/{id}")]
public async Task<ActionResult<Task>> UpdateTask(int id, UpdateTaskDto dto)
{
}
[HttpDelete("tasks/{id}")]
public async Task<ActionResult> DeleteTask(int id)
{
}
```
### 6.2 响应格式
```csharp
// 统一的响应格式
public class ApiResponse<T>
{
public bool Success { get; set; }
public T Data { get; set; }
public string Message { get; set; }
public List<string> Errors { get; set; }
}
// 成功响应
return Ok(new ApiResponse<Task>
{
Success = true,
Data = task,
Message = "Task created successfully"
});
// 错误响应
return BadRequest(new ApiResponse<object>
{
Success = false,
Message = "Validation failed",
Errors = new List<string> { "Title is required" }
});
```
## 7. Git 提交规范
### 7.1 提交信息格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
### 7.2 Type 类型
- `feat`: 新功能
- `fix`: 修复 bug
- `docs`: 文档更新
- `style`: 代码格式调整(不影响代码运行)
- `refactor`: 重构(既不是新功能也不是修复 bug)
- `perf`: 性能优化
- `test`: 测试相关
- `chore`: 构建过程或辅助工具的变动
### 7.3 示例
```
feat(api): add task completion endpoint
- Add PATCH /api/tasks/{id}/complete endpoint
- Update task service to handle completion logic
- Add unit tests for completion functionality
Closes #123
```
## 8. 测试规范
### 8.1 单元测试
```csharp
// 测试类命名: ClassName + Tests
public class TaskServiceTests
{
[Fact]
public async Task GetTasksAsync_ReturnsAllTasks()
{
// Arrange
var mockRepository = new Mock<ITaskRepository>();
var service = new TaskService(mockRepository.Object);
// Act
var result = await service.GetTasksAsync();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
}
}
```
### 8.2 集成测试
```csharp
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetTasks_ReturnsSuccessAndCorrectContentType()
{
// Act
var response = await _client.GetAsync("/api/tasks");
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
}
```
## 9. 代码审查清单
### 9.1 代码质量
- [ ] 代码符合项目规范
- [ ] 变量和函数命名清晰
- [ ] 没有重复代码
- [ ] 复杂逻辑有注释说明
- [ ] 没有硬编码的魔法数字
### 9.2 功能正确性
- [ ] 功能实现符合需求
- [ ] 边界条件已处理
- [ ] 错误处理完善
- [ ] 有相应的单元测试
### 9.3 性能和安全
- [ ] 没有性能问题
- [ ] 敏感数据已保护
- [ ] 输入验证完善
- [ ] 没有安全漏洞
## 10. 工具配置
### 10.1 C# 工具
- **代码格式化**: dotnet format
- **代码分析**: Roslyn Analyzers
- **代码风格**: .editorconfig
- **文档生成**: DocFX
### 10.2 JavaScript/TypeScript 工具
- **代码格式化**: Prettier
- **代码检查**: ESLint
- **类型检查**: TypeScript
- **代码风格**: .prettierrc
### 10.3 .editorconfig 示例
```ini
root = true
[*.cs]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,ts,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
```
+19
View File
@@ -0,0 +1,19 @@
# 其他信息
## 🤝 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](LICENSE) (英文) 或 [LICENSE.zh-CN](LICENSE.zh-CN) (中文) 文件了解详情
## 📞 联系方式
- 项目作者:ShaoHua
- 项目地址:https://git.we965.cn/Tools/Hua.Todo
- QQ 交流群:2167048911 (Hua.Todo 交流群)
+39
View File
@@ -0,0 +1,39 @@
# 技术栈与模块说明
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **UI 框架**MAUI (Multi-platform App UI)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript
- **框架**Vue.js 3
- **构建工具**Vite
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
## 🎯 核心模块说明
### Hua.Todo.Core
领域实体层,定义核心实体(TaskEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
### Hua.Todo.Application
应用层实现,包含业务逻辑、动态 API 生成逻辑、EF Core 数据库上下文以及具体的服务实现(TaskService)。
### Hua.Todo.Host
后端 API 宿主,提供运行环境和配置,是后端服务的启动入口。
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
### Hua.Todo.Maui
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
+356
View File
@@ -0,0 +1,356 @@
# Hua.Todo 技术设计文档 v1.1.0
## 1. 项目概述
本文档描述 Hua.Todo v1.1.0 的技术设计方案,包括项目文件目录结构、模块划分、技术选型和实现细节。
## 2. 技术栈
### 2.1 后端技术栈
- **开发语言**: C# 10
- **框架**: .NET 10
- **UI 框架**: MAUI (Multi-platform App UI)
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
- **API 框架**: ASP.NET Core Web API
- **数据访问**: Entity Framework Core
- **数据库**: SQLite (本地存储)
- **日志**: Serilog
- **依赖注入**: Microsoft.Extensions.DependencyInjection
### 2.2 前端技术栈
- **开发语言**: JavaScript/TypeScript
- **框架**: Vue.js 3
- **构建工具**: Vite
- **HTTP 客户端**: Axios
- **状态管理**: Pinia
- **UI 组件库**: Element Plus / Vant (移动端)
- **CSS 预处理器**: SCSS
## 3. 项目目录结构
```
Hua.Todo/
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ │ ├── 技术栈与模块.md
│ │ ├── 版本记录.md
│ │ ├── 技术设计文档.md(本文件)
│ │ └── 代码规范文档.md
│ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md
│ └── ...
├── src/ # 源代码目录
│ ├── Hua.Todo.Maui/ # MAUI 主项目(跨平台入口)
│ │ ├── Platforms/ # 平台特定代码
│ │ │ ├── Windows/ # Windows 平台代码
│ │ │ │ ├── App.xaml # Windows 应用入口
│ │ │ │ └── Services/ # Windows 平台服务
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── MacCatalyst/ # macOS 平台代码
│ │ │ │ ├── App.xaml
│ │ │ │ └── Services/
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── Android/ # Android 平台代码
│ │ │ │ ├── MainActivity.cs
│ │ │ │ └── Services/
│ │ │ │ └── NotificationService.cs
│ │ │ └── iOS/ # iOS 平台代码
│ │ │ ├── AppDelegate.cs
│ │ │ └── Services/
│ │ │ └── NotificationService.cs
│ │ ├── Resources/ # 资源文件
│ │ │ ├── Images/ # 图片资源
│ │ │ ├── Styles/ # 样式资源
│ │ │ └── Fonts/ # 字体资源
│ │ ├── Controls/ # 自定义控件
│ │ │ └── WebViewContainer.xaml
│ │ ├── Services/ # 服务层
│ │ │ ├── IHotKeyService.cs
│ │ │ ├── IPlatformService.cs
│ │ │ └── AppLifecycleService.cs
│ │ ├── App.xaml # MAUI 应用入口
│ │ ├── App.xaml.cs
│ │ ├── MauiProgram.cs # MAUI 程序配置
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
│ │
│ ├── Hua.Todo.Api/ # 后端 API 项目
│ │ ├── Controllers/ # API 控制器
│ │ │ ├── TasksController.cs
│ │ │ ├── SettingsController.cs
│ │ │ └── SyncController.cs
│ │ ├── Models/ # 数据模型
│ │ │ ├── Task.cs
│ │ │ ├── TaskDto.cs
│ │ │ └── ApiResponse.cs
│ │ ├── Services/ # 业务服务
│ │ │ ├── ITaskService.cs
│ │ │ ├── TaskService.cs
│ │ │ ├── ISyncService.cs
│ │ │ └── SyncService.cs
│ │ ├── Data/ # 数据访问层
│ │ │ ├── TodoDbContext.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Middleware/ # 中间件
│ │ │ └── ExceptionMiddleware.cs
│ │ ├── Extensions/ # 扩展方法
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ │
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/ # 实体类
│ │ │ ├── Task.cs
│ │ │ └── TaskPriority.cs
│ │ ├── Interfaces/ # 接口定义
│ │ │ ├── ITaskRepository.cs
│ │ │ └── IUnitOfWork.cs
│ │ ├── ValueObjects/ # 值对象
│ │ │ └── TaskTitle.cs
│ │ ├── Specifications/ # 规范模式
│ │ │ └── TaskSpecifications.cs
│ │ └── Hua.Todo.Core.csproj # Core 项目文件
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ │ └── index.html
│ │ ├── src/ # 源代码
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts # HTTP 客户端配置
│ │ │ │ ├── tasks.ts # 任务相关 API
│ │ │ │ └── settings.ts # 设置相关 API
│ │ │ ├── assets/ # 资源文件
│ │ │ │ ├── images/
│ │ │ │ └── styles/
│ │ │ ├── components/ # Vue 组件
│ │ │ │ ├── TaskList.vue
│ │ │ │ ├── TaskItem.vue
│ │ │ │ ├── QuickEntry.vue
│ │ │ │ └── Settings.vue
│ │ │ ├── composables/ # 组合式函数
│ │ │ │ ├── useTasks.ts
│ │ │ │ └── useHotKey.ts
│ │ │ ├── stores/ # 状态管理 (Pinia)
│ │ │ │ ├── tasks.ts
│ │ │ │ └── settings.ts
│ │ │ ├── types/ # TypeScript 类型定义
│ │ │ │ ├── task.ts
│ │ │ │ └── api.ts
│ │ │ ├── utils/ # 工具函数
│ │ │ │ ├── date.ts
│ │ │ │ └── storage.ts
│ │ │ ├── App.vue # 根组件
│ │ │ └── main.ts # 应用入口
│ │ ├── package.json # 依赖配置
│ │ ├── vite.config.ts # Vite 配置
│ │ ├── tsconfig.json # TypeScript 配置
│ │ └── index.html # HTML 模板
│ │
│ └── Hua.Todo.Tests/ # 测试项目
│ ├── Unit/ # 单元测试
│ │ ├── Services/
│ │ │ └── TaskServiceTests.cs
│ │ └── Controllers/
│ │ └── TasksControllerTests.cs
│ ├── Integration/ # 集成测试
│ │ └── ApiIntegrationTests.cs
│ └── Hua.Todo.Tests.csproj
├── .gitignore # Git 忽略文件
├── Hua.Todo.sln # 解决方案文件
└── README.md # 项目说明文档
```
## 4. 模块设计
### 4.1 MAUI 主项目 (Hua.Todo.Maui)
**职责**:
- 应用程序入口和生命周期管理
- 平台特定功能封装
- WebView 容器管理
- 本地 HTTP 服务器启动
**关键组件**:
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
- `App.xaml.cs`: 应用程序主入口
- `WebViewContainer`: 封装 WebView 控件
- 平台特定服务: 快捷键、通知等
### 4.2 后端 API 项目 (Hua.Todo.Api)
**职责**:
- 提供 RESTful API 接口
- 业务逻辑处理
- 数据访问和持久化
- 本地 HTTP 服务器托管
**关键组件**:
- `Controllers`: API 端点实现
- `Services`: 业务逻辑服务
- `Data`: 数据访问层和数据库上下文
- `Program.cs`: API 服务器配置和启动
### 4.3 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
- 实现领域驱动设计模式
**关键组件**:
- `Entities`: 领域实体
- `Interfaces`: 业务接口定义
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.4 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
- HTTP API 调用
- 状态管理
**关键组件**:
- `components`: Vue 组件
- `api`: API 调用封装
- `stores`: 状态管理
- `composables`: 组合式函数
## 5. HTTP API 设计
### 5.1 API 基础配置
- **基础 URL**: `http://localhost:5000/api`
- **数据格式**: JSON
- **认证方式**: 暂无(本地应用)
- **跨域配置**: 允许本地跨域请求
### 5.2 API 端点设计
#### 任务管理 API
```
GET /api/tasks # 获取任务列表
GET /api/tasks/{id} # 获取单个任务
POST /api/tasks # 创建任务
PUT /api/tasks/{id} # 更新任务
DELETE /api/tasks/{id} # 删除任务
PATCH /api/tasks/{id}/complete # 标记任务完成
```
#### 设置管理 API
```
GET /api/settings # 获取设置
PUT /api/settings # 更新设置
```
#### 同步 API
```
POST /api/sync/pull # 拉取远程数据
POST /api/sync/push # 推送本地数据
```
## 6. 数据库设计
### 6.1 数据库表结构
#### Tasks 表
```sql
CREATE TABLE Tasks (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Title TEXT NOT NULL,
Priority INTEGER NOT NULL DEFAULT 0,
IsCompleted INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
#### Settings 表
```sql
CREATE TABLE Settings (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
### 6.2 数据访问策略
- 使用 Entity Framework Core 进行数据访问
- 采用 Repository 模式封装数据访问
- 支持 LINQ 查询和异步操作
- 数据库迁移管理
## 7. 通信机制
### 7.1 HTTP 通信流程
1. **C# 后端启动**: MAUI 应用启动时启动本地 Kestrel 服务器
2. **Vue 前端加载**: WebView 加载 Vue 应用
3. **API 调用**: Vue 通过 Axios 调用本地 HTTP API
4. **数据处理**: C# 后端处理请求并返回 JSON 数据
5. **界面更新**: Vue 接收响应并更新界面
### 7.2 错误处理
- 统一的错误响应格式
- 异常中间件捕获和处理
- 前端错误提示和重试机制
## 8. 部署和打包
### 8.1 开发环境
- **后端调试**: 使用 Visual Studio 调试 MAUI 应用
- **前端调试**: 使用 Vite 开发服务器
- **热重载**: 支持前后端热重载
### 8.2 生产构建
- **前端构建**: `npm run build` 生成静态文件
- **后端打包**: MAUI 发布各平台应用
- **静态文件嵌入**: 将前端静态文件嵌入到 MAUI 应用中
### 8.3 平台特定配置
- **Windows**: WebView2 运行时要求
- **macOS**: 代码签名和公证
- **移动端**: 应用商店发布配置
- **Linux**: .NET MAUI 无官方 Linux 目标,需要引入独立桌面宿主(例如基于 WebKitGTK 的方案)并处理运行时依赖与打包格式
## 9. 性能优化
### 9.1 前端优化
- 组件懒加载
- 虚拟滚动(长列表)
- 图片懒加载
- 缓存策略
### 9.2 后端优化
- 数据库查询优化
- 响应缓存
- 异步处理
- 连接池管理
## 10. 安全考虑
### 10.1 本地安全
- 本地服务器仅监听 localhost
- 防止外部访问
- 数据加密存储(可选)
### 10.2 数据安全
- 数据库文件权限控制
- 定期备份机制
- 敏感数据保护
## 11. 测试策略
### 11.1 单元测试
- 核心业务逻辑测试
- 服务层测试
- 工具函数测试
### 11.2 集成测试
- API 集成测试
- 数据库集成测试
- 前后端集成测试
### 11.3 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
+36
View File
@@ -0,0 +1,36 @@
# 版本更新历史
## 🔄 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### v1.1.1 (2026-04-06)
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
- **项目结构说明校准**:修正 README.md 和技术文档中对 `Hua.Todo.Host``Hua.Todo.Application` 等模块的路径与职责描述。
- **端口配置校准**:修正文档中关于前端与后端 API 的端口说明(5173/5174)。
- **PRD 校准**:移除 v1.2.0 PRD 中“本地迭代不支持”表述与“数据迁移(导入/导出)”小节。
- **PRD 校准**:移除 v1.2.0 PRD 中“云同步”需求。
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持 (Windows, macOS, Android, iOS)
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
- 实现子任务支持
### v1.2.0 规划内容 (即将推出)
- **Linux 官方支持**:正式适配 Linux 平台。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
-9
View File
@@ -1,14 +1,5 @@
# Hua.Todo 产品需求文档 (PRD) v1.1.0
> **术语对照(必读)**
>
> | 术语 | 含义 | 出现位置 |
> |---|---|---|
> | **Todo 待办项 / 任务 / 子任务(业务实体)** | 用户在 Hua.Todo 应用中创建的待办事项,对应代码 `Task` / `SubTask` 实体、`/api/task` 接口、UI 列表项 | 本文及 `docs/manual/`、业务代码 |
> | **研发工单(Dev Work Item)** | 智能体 / 开发者执行的编码工作项(需求拆分、并行开发) | `docs/project/研发工单-*/`、`.trae/rules/全局/05-研发工单规则.md` |
>
> 本文中"任务/子任务"统一指业务实体;如涉及编码侧拆分请使用"研发工单"。
## 1. 项目概述
本项目是一个基于 MAUI + WebView 架构开发的跨平台代办管理应用 (Hua.Todo)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。v1.1.0 版本将实现跨平台支持,覆盖 Windows、macOS、Android、iOS 和 Linux(预览)平台。
-9
View File
@@ -1,14 +1,5 @@
# Hua.Todo 产品需求文档 (PRD) v1.2.0
> **术语对照(必读)**
>
> | 术语 | 含义 | 出现位置 |
> |---|---|---|
> | **Todo 待办项 / 任务 / 子任务(业务实体)** | 用户在 Hua.Todo 应用中创建的待办事项,对应代码 `Task` / `SubTask` 实体、`/api/task` 接口、UI 列表项 | 本文及 `docs/manual/`、业务代码 |
> | **研发工单(Dev Work Item)** | 智能体 / 开发者执行的编码工作项(需求拆分、并行开发) | `docs/project/研发工单-*/`、`.trae/rules/全局/05-研发工单规则.md` |
>
> 本文中"任务/子任务"统一指业务实体;如涉及编码侧拆分请使用"研发工单"。
## 1. 项目概述
在 v1.1.0 版本成功实现 MAUI + WebView 跨平台架构的基础上,v1.2.0 版本将以“**MAUI 入口保持不动 + Linux 新增 Avalonia 入口**”的方式落地 Linux 支持(继续使用 WebView 承载 Vue 前端),并在此基础上探索 Avalonia 对其他终端的支持情况;若验证效果良好,后续版本将推进各终端入口统一切换到 Avalonia。
-9
View File
@@ -1,14 +1,5 @@
# Hua.Todo 产品需求文档 (PRD)
> **术语对照(必读)**
>
> | 术语 | 含义 | 出现位置 |
> |---|---|---|
> | **Todo 待办项 / 任务 / 子任务(业务实体)** | 用户在 Hua.Todo 应用中创建的待办事项,对应代码 `Task` / `SubTask` 实体、`/api/task` 接口、UI 列表项 | 本文及 `docs/manual/`、业务代码 |
> | **研发工单(Dev Work Item)** | 智能体 / 开发者执行的编码工作项(需求拆分、并行开发) | `docs/project/研发工单-*/`、`.trae/rules/全局/05-研发工单规则.md` |
>
> 本文中"任务/子任务"统一指业务实体;如涉及编码侧拆分请使用"研发工单"。
## 1. 项目概述
本项目是一个基于 C# WPF (.NET 10) 开发的桌面代办管理应用 (Hua.Todo)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。
+50 -135
View File
@@ -1,147 +1,62 @@
param(
[switch]$Windows,
[switch]$Linux,
[string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"),
[switch]$LinuxSelfContained,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
[switch]$SkipWindowsVersionBump
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$WebDir = Join-Path $ScriptPath "src\Hua.Todo.Web"
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
# 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
}
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
# 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
}
Start-Sleep -Seconds 2
}
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-Host "No conflicting processes found." -ForegroundColor Green
Write-Error "Packaging failed"
}
} else {
Write-Error "Inno Setup compiler not found"
}
function Build-Web {
Write-Host "Building Web artifacts..." -ForegroundColor Cyan
if (!(Test-Path $WebDir)) {
Write-Error "Web directory not found: $WebDir"
exit 1
}
$versionParts = $currentVersion.Split(".")
$patch = [int]$versionParts[2] + 1
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
if (!(Test-Path (Join-Path $WebDir "node_modules"))) {
Write-Host "Installing npm dependencies..." -ForegroundColor Yellow
Push-Location $WebDir
npm install
Pop-Location
}
Write-Host "Running Web builds in parallel..." -ForegroundColor Yellow
$webJobs = @()
$webJobs += Start-Job -ScriptBlock {
param($dir)
Set-Location $dir
npm run build
} -ArgumentList $WebDir
$webJobs += Start-Job -ScriptBlock {
param($dir)
Set-Location $dir
npm run build -- --mode maui
} -ArgumentList $WebDir
$webJobs | Wait-Job | Receive-Job
}
function Bump-Version {
if (Test-Path $DirectoryBuildProps) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
$currentVersion = $versionNode.InnerText.Trim()
$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)
$propsContent = Get-Content $DirectoryBuildProps -Raw
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
Write-Host "Bumped version in Directory.Build.props: $currentVersion -> $newVersion" -ForegroundColor Green
}
}
}
}
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
$shouldPublishWindows = $Windows.IsPresent
$shouldPublishLinux = $Linux.IsPresent
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishWindows = $true
$shouldPublishLinux = $true
}
# 1. Cleanup
Stop-ProjectProcesses
# 2. Build Web
Build-Web
# 3. App Publishing
# NOTE: To avoid file locks in 'obj' and 'bin' folders, we publish the .NET apps sequentially.
# However, we can parallelize the Inno Setup packaging later if needed.
Write-Host "Starting app publishing..." -ForegroundColor Cyan
if ($shouldPublishWindows) {
Write-Host "Publishing Windows Apps..." -ForegroundColor Cyan
# Maui Windows (Skip ISS here, we'll do it later if parallel is needed, or just let it run)
& $publishWindowsScript -AppType Maui -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
# Avalonia Windows
& $publishWindowsScript -AppType Avalonia -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
}
if ($shouldPublishLinux) {
Write-Host "Publishing Linux Apps..." -ForegroundColor Cyan
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript -RuntimeIdentifier $rid -SelfContained:$LinuxSelfContained -Configuration $Configuration -SkipProcessStop
}
}
# 4. Finalizing
if (!$SkipWindowsVersionBump.IsPresent) {
Bump-Version
}
Write-Host "All publishing tasks completed!" -ForegroundColor Green
$content = Get-Content $ProjectFile -Raw
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content
+4 -103
View File
@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
using Hua.Todo.Application.Data.Converters;
namespace Hua.Todo.Application.Data;
@@ -18,30 +17,10 @@ public class TodoDbContext : DbContext
}
/// <summary>
/// 任务集合ABP 表名规范:T_Tasks
/// 任务集合。
/// </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>
public DbSet<AuditLogEntity> AuditLogs { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
@@ -50,98 +29,20 @@ public class TodoDbContext : DbContext
{
base.OnModelCreating(modelBuilder);
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
// TaskEntity 配置(ABP 风格审计字段)
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("T_Tasks");
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.ParentTaskId).HasColumnType("TEXT");
// ABP 审计字段(基类 FullAuditedEntityWithUser 提供)
entity.Property(e => e.ExtraProperties).HasColumnType("TEXT");
entity.Property(e => e.ConcurrencyStamp).HasColumnType("TEXT");
entity.Property(e => e.CreationTime).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.CreatorId).HasColumnType("TEXT");
entity.Property(e => e.LastModificationTime).HasConversion(utcDateTimeConverter);
entity.Property(e => e.LastModifierId).HasColumnType("TEXT");
entity.Property(e => e.IsDeleted).HasDefaultValue(false);
entity.Property(e => e.DeletionTime).HasConversion(utcDateTimeConverter);
entity.Property(e => e.DeleterId).HasColumnType("TEXT");
// 软删除查询过滤器
entity.HasQueryFilter(e => !e.IsDeleted);
// 导航属性
entity.HasOne(e => e.User)
.WithMany(u => u.Tasks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')");
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().HasConversion(utcDateTimeConverter);
entity.Property(e => e.ExpiresAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.StepUpExpiresAtUtc).HasConversion(utcDateTimeConverter);
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.UserId).IsRequired();
entity.Property(e => e.AllowPersist).HasDefaultValue(true);
entity.Property(e => e.AllowSync).HasDefaultValue(true);
entity.Property(e => e.SecondFactorExpiryMinutes).HasDefaultValue(30);
entity.Property(e => e.IsTrustedDeviceOnly).HasDefaultValue(false);
entity.HasIndex(e => e.UserId).IsUnique();
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<AuditLogEntity>(entity =>
{
entity.ToTable("AuditLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.TimestampUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.EventType).IsRequired().HasMaxLength(64);
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.ClientIp).HasMaxLength(64);
entity.Property(e => e.UserAgent).HasMaxLength(500);
entity.HasIndex(e => e.UserId);
entity.HasIndex(e => e.TimestampUtc);
});
}
}
@@ -68,15 +68,6 @@ public class DynamicApiMiddleware
var path = context.Request.Path.Value ?? string.Empty;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
// 处理 /api 根路径探测请求:返回 200 表示服务在线
if (segments.Length == 1 && segments[0] == "api")
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = 200;
await context.Response.WriteAsync("{\"status\":\"ok\"}");
return;
}
if (segments.Length < 2 || segments[0] != "api")
{
await _next(context);
@@ -1,10 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- 当指定桌面平台 RID 或显式标记为桌面构建时,仅保留 net10.0 目标框架,避免还原阶段为移动端 TFM 解析 Mono runtime pack。 -->
<IsDesktopRID Condition="'$(IsDesktopBuild)' == 'true' or ('$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-'))))">true</IsDesktopRID>
<TargetFrameworks Condition="'$(IsDesktopRID)' == 'true'">net10.0</TargetFrameworks>
<TargetFrameworks Condition="'$(IsDesktopRID)' != 'true'">net10.0;net10.0-android36.0;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
@@ -12,19 +9,13 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="CloudSync\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
@@ -3,66 +3,66 @@ using Hua.Todo.Application.Models;
namespace Hua.Todo.Application.Interfaces;
/// <summary>
/// 任务管理服务接口
/// 任务管理服务接口
/// </summary>
public interface ITaskService : IDynamicApiService
{
/// <summary>
/// 获取所有任务
/// 获取所有任务
/// </summary>
/// <returns>包含所有任务的列表</returns>
/// <returns>包含所有任务的列表</returns>
Task<List<TaskDto>> GetAllTasksAsync();
/// <summary>
/// 根据 ID 获取任务
/// 根据 ID 获取任务
/// </summary>
/// <param name="id">任务 ID。</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(Guid id);
/// <param name="id">任务唯一标识符</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(int id);
/// <summary>
/// 获取未完成的任务
/// 获取未完成的任务
/// </summary>
/// <returns>未完成任务的列表</returns>
/// <returns>未完成任务的列表</returns>
Task<List<TaskDto>> GetActiveTasksAsync();
/// <summary>
/// 获取已完成的任务
/// 获取已完成的任务
/// </summary>
/// <returns>已完成任务的列表</returns>
/// <returns>已完成任务的列表</returns>
Task<List<TaskDto>> GetCompletedTasksAsync();
/// <summary>
/// 创建新任务
/// 创建新任务
/// </summary>
/// <param name="dto">创建任务所需的数据传输对象</param>
/// <returns>创建成功的任务 DTO</returns>
/// <param name="dto">创建任务所需的数据传输对象</param>
/// <returns>创建成功的任务 DTO</returns>
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
/// <summary>
/// 更新任务信息
/// 更新任务信息
/// </summary>
/// <param name="dto">包含更新信息的任务数据传输对象</param>
/// <returns>更新后的任务 DTO</returns>
/// <param name="dto">包含更新信息的任务数据传输对象</param>
/// <returns>更新后的任务 DTO</returns>
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
/// <summary>
/// 切换任务完成状态
/// 切换任务完成状态
/// </summary>
/// <param name="id">任务 ID。</param>
/// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(Guid id);
/// <param name="id">任务唯一标识符</param>
/// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(int id);
/// <summary>
/// 删除任务
/// 删除任务
/// </summary>
/// <param name="id">要删除的任务 ID。</param>
Task DeleteTaskAsync(Guid id);
/// <param name="id">要删除的任务唯一标识符</param>
Task DeleteTaskAsync(int id);
/// <summary>
/// 获取子任务列表
/// 获取子任务列表
/// </summary>
/// <param name="parentTaskId">父任务 ID。</param>
/// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId);
/// <param name="parentTaskId">父任务唯一标识符</param>
/// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
}
@@ -1,9 +1,9 @@
// <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,137 +17,24 @@ namespace Hua.Todo.Application.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TimestampUtc");
b.HasIndex("UserId");
b.ToTable("AuditLogs", (string)null);
});
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<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
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<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreationTime")
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid?>("CreatorId")
.HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("TEXT");
b.Property<string>("ExtraProperties")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("TEXT");
b.Property<Guid?>("LastModifierId")
.HasColumnType("TEXT");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("TEXT");
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
@@ -159,97 +46,16 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("T_Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("MustChangePassword")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.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<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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");
b.ToTable("Tasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
@@ -259,37 +65,13 @@ 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
}
}
+10 -23
View File
@@ -13,16 +13,16 @@ public class CreateTaskDto
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 父任务 ID(用于创建子任务)。
/// </summary>
public Guid? ParentTaskId { get; set; }
public int? ParentTaskId { get; set; }
}
/// <summary>
@@ -33,17 +33,17 @@ public class UpdateTaskDto
/// <summary>
/// 任务 ID。
/// </summary>
public Guid Id { get; set; }
public int Id { get; set; }
/// <summary>
/// 新标题(可选)。
/// </summary>
public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 新优先级(可选)。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; }
}
@@ -55,44 +55,34 @@ public class TaskDto
/// <summary>
/// 任务 ID。
/// </summary>
public Guid Id { get; set; }
public int Id { get; set; }
/// <summary>
/// 任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否已完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 任务编号(用户级自增字符串)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreationTime { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// 最后修改时间(UTC)。
/// 更新时间(UTC)。
/// </summary>
public DateTime? LastModificationTime { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public Guid? ParentTaskId { get; set; }
public int? ParentTaskId { get; set; }
/// <summary>
/// 子任务列表。
/// </summary>
@@ -109,17 +99,14 @@ public class ApiResponse<T>
/// 是否成功。
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 返回数据(可选)。
/// </summary>
public T? Data { get; set; }
/// <summary>
/// 提示信息。
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// 错误列表。
/// </summary>
@@ -22,17 +22,13 @@ public class TaskRepository : ITaskRepository
}
/// <summary>
/// 获取所有任务(本地用户),平面加载以支持任意深度树构建
/// 不再使用 Include(t => t.SubTasks) 因为 EF Core 无法递归加载未知深度的子任务;
/// 树的构建交由 <see cref="TaskService.GetAllTasksAsync"/> 通过 parentTaskId 在内存中完成。
/// 获取所有任务。
/// </summary>
/// <returns>包含所有任务实体的平面列表。</returns>
/// <returns>包含所有任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.AsNoTracking()
.Where(t => t.UserId == TodoUserIds.LocalUserId)
.OrderByDescending(t => t.CreationTime)
.Include(t => t.SubTasks)
.ToListAsync();
}
@@ -41,11 +37,11 @@ public class TaskRepository : ITaskRepository
/// </summary>
/// <param name="id">任务 ID。</param>
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
public async Task<TaskEntity?> GetByIdAsync(Guid id)
public async Task<TaskEntity?> GetByIdAsync(int id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
.FirstOrDefaultAsync(t => t.Id == id);
}
/// <summary>
@@ -55,8 +51,8 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
.OrderByDescending(t => t.CreationTime)
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
@@ -67,8 +63,8 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
.OrderByDescending(t => t.LastModificationTime)
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
@@ -79,13 +75,6 @@ public class TaskRepository : ITaskRepository
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
taskEntity.UserId = TodoUserIds.LocalUserId;
if (taskEntity.Id == Guid.Empty)
{
taskEntity.Id = Guid.NewGuid();
}
taskEntity.CreationTime = DateTime.UtcNow;
taskEntity.CreatorId = taskEntity.UserId;
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
@@ -98,20 +87,20 @@ public class TaskRepository : ITaskRepository
/// <returns>更新后的任务实体。</returns>
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{
taskEntity.LastModificationTime = DateTime.UtcNow;
taskEntity.LastModifierId = taskEntity.UserId;
taskEntity.UpdatedAt = DateTime.UtcNow;
_context.Tasks.Update(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
/// <summary>
/// 根据 ID 删除任务(物理删除)
/// 根据 ID 删除任务。
/// </summary>
/// <param name="id">要删除的任务 ID。</param>
public async Task DeleteAsync(Guid id)
/// <returns>表示删除操作的任务。</returns>
public async Task DeleteAsync(int id)
{
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
var task = await _context.Tasks.FindAsync(id);
if (task != null)
{
_context.Tasks.Remove(task);
@@ -124,11 +113,11 @@ public class TaskRepository : ITaskRepository
/// </summary>
/// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId)
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreationTime)
.Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
}
@@ -6,69 +6,46 @@ using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Services;
/// <summary>
/// 任务管理服务实现
/// 任务管理服务实现
/// </summary>
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
/// <summary>
/// 初始化任务管理服务的新实例
/// 初始化任务管理服务的新实例
/// </summary>
/// <param name="taskRepository">任务仓储接口</param>
/// <param name="taskRepository">任务仓储接口</param>
public TaskService(ITaskRepository taskRepository)
{
_taskRepository = taskRepository;
}
/// <summary>
/// 获取所有任务(包含任意深度的子树)。
/// 先从 DB 平面加载全部任务,再在内存中通过 parentTaskId 构建树结构。
/// 获取所有任务
/// </summary>
/// <returns>完整的任务 DTO 列表</returns>
/// <returns>所有任务 DTO 列表</returns>
public async Task<List<TaskDto>> GetAllTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
// 构建 DTO 字典(平面)
var dtoMap = new Dictionary<Guid, TaskDto>();
foreach (var task in allTasks)
{
dtoMap[task.Id] = MapToDto(task);
}
// 通过 parentTaskId 在内存中构建树结构(支持任意深度)
var roots = new List<TaskDto>();
foreach (var dto in dtoMap.Values)
{
if (dto.ParentTaskId.HasValue && dtoMap.TryGetValue(dto.ParentTaskId.Value, out var parent))
{
parent.SubTasks.Add(dto);
}
else
{
roots.Add(dto);
}
}
return roots;
var tasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList();
}
/// <summary>
/// 根据 ID 获取任务详情
/// 根据 ID 获取任务详情
/// </summary>
/// <param name="id">任务 ID</param>
/// <returns>找到的任务 DTO,如果不存在则返回 null</returns>
public async Task<TaskDto?> GetTaskByIdAsync(Guid id)
/// <param name="id">任务 ID</param>
/// <returns>找到的任务 DTO,如果不存在则返回 null</returns>
public async Task<TaskDto?> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null;
}
/// <summary>
/// 获取所有未完成的任务
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务的 DTO 列表</returns>
/// <returns>未完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetActiveTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
@@ -76,9 +53,9 @@ public class TaskService : ITaskService
}
/// <summary>
/// 获取所有已完成的任务
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务的 DTO 列表</returns>
/// <returns>已完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetCompletedTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
@@ -86,24 +63,19 @@ public class TaskService : ITaskService
}
/// <summary>
/// 创建新任务
/// 创建新任务
/// </summary>
/// <param name="dto">任务创建 DTO</param>
/// <returns>新创建的任务 DTO</returns>
/// <param name="dto">任务创建 DTO</param>
/// <returns>新创建的任务 DTO</returns>
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{
var allExisting = await _taskRepository.GetAllAsync();
var maxCode = allExisting.Count > 0
? allExisting.Max(t => int.TryParse(t.Code, out var c) ? c : 0)
: 0;
var task = new TaskEntity
{
Id = Guid.NewGuid(),
Title = dto.Title,
Priority = dto.Priority,
IsCompleted = false,
Code = (maxCode + 1).ToString(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = dto.ParentTaskId
};
@@ -112,11 +84,11 @@ public class TaskService : ITaskService
}
/// <summary>
/// 更新现有任务
/// 更新现有任务
/// </summary>
/// <param name="dto">包含更新内容的 DTO</param>
/// <returns>更新后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
/// <param name="dto">包含更新内容的 DTO</param>
/// <returns>更新后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{
var task = await _taskRepository.GetByIdAsync(dto.Id);
@@ -135,17 +107,19 @@ public class TaskService : ITaskService
task.Priority = dto.Priority.Value;
}
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask);
}
/// <summary>
/// 切换任务的完成状态
/// 切换任务的完成状态
/// </summary>
/// <param name="id">任务 ID</param>
/// <returns>状态切换后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> ToggleCompleteAsync(Guid id)
/// <param name="id">任务 ID</param>
/// <returns>状态切换后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> ToggleCompleteAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
@@ -154,17 +128,18 @@ public class TaskService : ITaskService
}
task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask);
}
/// <summary>
/// 删除指定 ID 的任务
/// 删除指定 ID 的任务
/// </summary>
/// <param name="id">任务 ID</param>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task DeleteTaskAsync(Guid id)
/// <param name="id">任务 ID</param>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task DeleteTaskAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
@@ -176,21 +151,20 @@ public class TaskService : ITaskService
}
/// <summary>
/// 获取指定父任务的所有子任务
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务 ID</param>
/// <returns>子任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
/// <param name="parentTaskId">父任务 ID</param>
/// <returns>子任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId)
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
}
/// <summary>
/// 实体转 DTO 映射方法
/// 注意:SubTasks 默认为空列表,由 <see cref="GetAllTasksAsync"/> 在内存中通过 parentTaskId 构建树。
/// 实体转 DTO 映射方法
/// </summary>
private static TaskDto MapToDto(TaskEntity task)
private TaskDto MapToDto(TaskEntity task)
{
return new TaskDto
{
@@ -198,11 +172,10 @@ public class TaskService : ITaskService
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
Code = task.Code,
CreationTime = task.CreationTime,
LastModificationTime = task.LastModificationTime,
CreatedAt = task.CreatedAt,
UpdatedAt = task.UpdatedAt,
ParentTaskId = task.ParentTaskId,
SubTasks = new List<TaskDto>()
SubTasks = task.SubTasks.Select(MapToDto).ToList()
};
}
}
+21 -23
View File
@@ -1,54 +1,52 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 任务实体类,表示一个 Todo 待办项。
/// 继承 <see cref="FullAuditedEntityWithUser{Guid, UserEntity}"/> 以获得 ABP 风格的审计字段。
/// 任务实体类,表示一个代办
/// </summary>
public class TaskEntity : FullAuditedEntityWithUser<Guid, UserEntity>
public class TaskEntity
{
/// <summary>
/// 任务所属用户 ID(云端隔离),本地模式为 <see cref="TodoUserIds.LocalUserId"/>。
/// 任务唯一标识符
/// </summary>
public Guid UserId { get; set; } = TodoUserIds.LocalUserId;
public int Id { get; set; }
/// <summary>
/// 任务所属用户导航属性。
/// </summary>
public UserEntity? User { get; set; }
/// <summary>
/// 任务标题。
/// 任务标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 任务优先级
/// 任务优先级
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 任务编号(用户级自增字符串,用于展示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 任务是否已完成。
/// 任务是否已完成
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 任务 ID(外键),用于支持子任务功能。
/// 类型为 <see cref="Guid"/>,与主键类型一致。
/// 任务创建时间(UTC
/// </summary>
public Guid? ParentTaskId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 任务导航属性。
/// 任务最后更新时间(UTC
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 父任务ID,用于支持子任务功能
/// </summary>
public int? ParentTaskId { get; set; }
/// <summary>
/// 父任务导航属性
/// </summary>
public TaskEntity? ParentTask { get; set; }
/// <summary>
/// 子任务集合
/// 子任务集合
/// </summary>
public List<TaskEntity> SubTasks { get; set; } = new();
}
+2
View File
@@ -4,6 +4,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
+25 -24
View File
@@ -3,59 +3,60 @@ using Hua.Todo.Core.Entities;
namespace Hua.Todo.Core.Interfaces;
/// <summary>
/// 任务仓储接口,定义任务数据访问操作
/// 任务仓储接口,定义任务数据访问操作
/// </summary>
public interface ITaskRepository
{
/// <summary>
/// 获取所有任务
/// 获取所有任务
/// </summary>
/// <returns>包含所有任务实体的列表</returns>
/// <returns>包含所有任务实体的列表任务</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetAllAsync();
/// <summary>
/// 根据 ID 获取任务
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务 ID。</param>
/// <returns>匹配的任务实体如果不存在则返回 null</returns>
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(Guid id);
/// <param name="id">任务唯一标识符</param>
/// <returns>任务实体对象,如果不存在则返回 null 的任务</returns>
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(int id);
/// <summary>
/// 获取未完成任务列表。
/// 获取所有未完成任务
/// </summary>
/// <returns>未完成任务实体列表</returns>
/// <returns>未完成任务实体列表任务</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetActiveTasksAsync();
/// <summary>
/// 获取已完成任务列表。
/// 获取所有已完成任务
/// </summary>
/// <returns>已完成任务实体列表</returns>
/// <returns>已完成任务实体列表任务</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetCompletedTasksAsync();
/// <summary>
/// 新增一个任务
/// 添加新任务
/// </summary>
/// <param name="taskEntity">要添加的任务实体</param>
/// <returns>已持久化的任务实体(包含生成的 ID</returns>
/// <param name="taskEntity">要添加的任务实体对象</param>
/// <returns>添加后的任务实体(包含生成的 ID的任务</returns>
System.Threading.Tasks.Task<TaskEntity> AddAsync(TaskEntity taskEntity);
/// <summary>
/// 更新现有任务信息。
/// 更新任务
/// </summary>
/// <param name="taskEntity">要更新的任务实体</param>
/// <returns>更新后的任务实体</returns>
/// <param name="taskEntity">要更新的任务实体对象</param>
/// <returns>更新后的任务实体对象的任务</returns>
System.Threading.Tasks.Task<TaskEntity> UpdateAsync(TaskEntity taskEntity);
/// <summary>
/// 根据 ID 删除任务(物理删除)。
/// 删除指定ID的任务
/// </summary>
/// <param name="id">要删除的任务 ID。</param>
System.Threading.Tasks.Task DeleteAsync(Guid id);
/// <param name="id">要删除的任务唯一标识符</param>
/// <returns>表示删除操作的任务</returns>
System.Threading.Tasks.Task DeleteAsync(int id);
/// <summary>
/// 获取指定父任务的子任务列表。
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId);
/// <param name="parentTaskId">父任务唯一标识符</param>
/// <returns>子任务实体的列表任务</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId);
}
+7 -71
View File
@@ -1,20 +1,15 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.DynamicApi.Swagger;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Application.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.DocumentFilter<DynamicApiSwaggerDocumentFilter>();
});
builder.Services.AddCloudSyncServer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthorization();
builder.Services.AddApplicationServices("Data Source=Hua.Todo.db");
@@ -24,69 +19,17 @@ builder.Services.AddCors(options =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.SetPreflightMaxAge(TimeSpan.FromMinutes(30));
.AllowAnyHeader();
});
});
var app = builder.Build();
// Apply database migrations and seed default admin
// Apply database migrations
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var dbContext = services.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
var dbContext = scope.ServiceProvider.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
dbContext.Database.Migrate();
var config = services.GetRequiredService<IConfiguration>();
var authService = services.GetRequiredService<CloudAuthService>();
var adminConfig = config.GetSection("DefaultAdmin");
var adminUserName = adminConfig["UserName"];
var adminPassword = adminConfig["Password"];
if (!string.IsNullOrWhiteSpace(adminUserName))
{
var result = await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None);
if (result != null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("========================================");
Console.WriteLine("首次启动:已创建默认管理员账号");
Console.WriteLine($"用户名: {result.UserName}");
if (!string.IsNullOrEmpty(adminPassword))
{
Console.WriteLine($"密码: {adminPassword}(已在配置中设置)");
}
else
{
Console.WriteLine($"临时密码: {result.GeneratedPassword}");
Console.WriteLine("请立即登录并修改密码!");
}
Console.WriteLine("========================================");
Console.ResetColor();
}
}
else
{
var hasAnyCloudUser = await dbContext.Users.AsNoTracking().AnyAsync(u => u.Role != "local");
if (!hasAnyCloudUser)
{
var defaultUserName = "admin";
var result = await authService.BootstrapAdminAsync(defaultUserName, null, "system", "auto-seeding", CancellationToken.None);
if (result != null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("========================================");
Console.WriteLine("首次启动:已自动创建管理员账号");
Console.WriteLine($"用户名: {result.UserName}");
Console.WriteLine($"临时密码: {result.GeneratedPassword}");
Console.WriteLine("请立即登录并修改密码!");
Console.WriteLine("========================================");
Console.ResetColor();
}
}
}
}
if (app.Environment.IsDevelopment())
@@ -95,16 +38,9 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseCors("AllowAll");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAuthentication();
app.UseCors("AllowAll");
app.UseAuthorization();
// 简单的管理后台入口跳转
app.MapGet("/admin", () => Results.Redirect("/admin/index.html"));
app.MapCloudSyncEndpoints();
app.UseDynamicApi();
app.Run();
+1 -5
View File
@@ -5,9 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"DefaultAdmin": {
"UserName": "admin",
"Password": "123456"
}
"AllowedHosts": "*"
}
+28 -67
View File
@@ -1,10 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<!-- 在 Windows 平台构建时,追加 Windows 目标框架,同时保留 Android,避免覆盖。 -->
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
@@ -14,7 +12,6 @@
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<AndroidApplication>true</AndroidApplication>
<RootNamespace>Hua.Todo.Maui</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
@@ -29,13 +26,18 @@
<ApplicationTitle>代办</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.hua.todo</ApplicationId>
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<!-- Versions -->
<!-- Unified in Directory.Build.props -->
<Version>1.1.5</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- Assembly Info -->
<!-- Unified in Directory.Build.props -->
<AssemblyTitle>待办</AssemblyTitle>
<AssemblyProduct>待办</AssemblyProduct>
<AssemblyCompany>Hua.Todo</AssemblyCompany>
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
@@ -50,11 +52,6 @@
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<ForceWebBuild>false</ForceWebBuild>
<ForceFullAndroidDeploy>false</ForceFullAndroidDeploy>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
@@ -67,16 +64,13 @@
<Optimize>False</Optimize>
<EnableMauiImageProcessing>false</EnableMauiImageProcessing>
<DisableResizetizer>true</DisableResizetizer>
<!-- Android 调试默认跳过前端构建,减少每次 F5 等待时间。 -->
<SkipWebBuild>true</SkipWebBuild>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
<!-- .NET 10 Preview 环境下,Shared Runtime 和 Fast Deployment 往往会导致 AndroidX 依赖解析异常 (NoClassDefFoundError)。
为了稳定性,默认启用完整部署模式 (EmbedAssembliesIntoApk=True)。 -->
<AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>False</AndroidEnableFastDeployment>
<EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
<RuntimeIdentifiers>android-x64</RuntimeIdentifiers>
<AndroidUseSharedRuntime>True</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>True</AndroidEnableFastDeployment>
<AndroidFastDeploymentType>Assemblies</AndroidFastDeploymentType>
<EmbedAssembliesIntoApk>False</EmbedAssembliesIntoApk>
<AndroidSupportedAbis>x86_64</AndroidSupportedAbis>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
@@ -97,12 +91,12 @@
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#F8FAFC" BaseSize="128,128" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\icon.jpg" Resize="True" BaseSize="300,185" />
<MauiImage Include="icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
@@ -126,7 +120,6 @@
<ItemGroup>
<EmbeddedResource Include="icon.ico" />
<EmbeddedResource Include="Platforms\Android\Resources\drawable\icon.ico" />
</ItemGroup>
<ItemGroup>
@@ -134,21 +127,17 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And '$(Configuration)' == 'Debug'">
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
</ItemGroup>
@@ -157,19 +146,13 @@
<Folder Include="wwwroot\" />
</ItemGroup>
<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="Exists('$(TodoWebDir)') And (!Exists('$(MSBuildProjectDirectory)\wwwroot\index.html') Or '$(SkipWebBuild)' != 'true' Or '$(ForceWebBuild)' == 'true')">
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;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:maui" WorkingDirectory="$(TodoWebDir)" />
<Exec Command="npm run build" WorkingDirectory="$(TodoWebDir)" />
</Target>
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And '$(UseDistSyncToWwwroot)' == 'true' And Exists('$(TodoWebDistDir)\index.html')">
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoWebDistDir)\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
@@ -181,33 +164,20 @@
<ItemGroup>
<MauiAsset Include="@(_TodoWebDistFiles)">
<LogicalName>$([System.String]::Copy('wwwroot/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))</LogicalName>
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="BeforeBuild;ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android'">
<Target Name="CopyTodoWebDistToWindowsWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And Exists('$(TodoWebDistDir)')">
<ItemGroup>
<!-- 确保包含所有 wwwroot 下的文件,包括子目录。 -->
<_MauiWwwrootFiles Include="wwwroot\**\*" />
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
<ItemGroup>
<!-- 显式设置 Link 和 LogicalName。
在 Android 上,LogicalName 决定了 AssetManager.Open() 的路径。 -->
<MauiAsset Include="@(_MauiWwwrootFiles)">
<Link>wwwroot\%(RecursiveDir)%(Filename)%(Extension)</Link>
<LogicalName>$([System.String]::Copy('wwwroot/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))</LogicalName>
</MauiAsset>
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
<MakeDir Directories="$(TargetDir)wwwroot" />
<!-- 同时尝试在根目录也放一份 index.html,增加容错。 -->
<MauiAsset Include="wwwroot\index.html" Condition="Exists('wwwroot\index.html')">
<Link>index.html</Link>
<LogicalName>index.html</LogicalName>
</MauiAsset>
</ItemGroup>
<Message Importance="high" Text="[Build] Included $(_MauiWwwrootFiles->Count()) assets from wwwroot into MauiAsset (with wwwroot/ prefix and root fallback)" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -219,12 +189,3 @@
+3 -18
View File
@@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.Data;
using Hua.Todo.Maui.Models;
using Hua.Todo.Maui.Services;
@@ -14,14 +13,13 @@ namespace Hua.Todo.Maui;
/// <summary>
/// MAUI 程序启动类
/// </summary>
public static partial class MauiProgram
public static class MauiProgram
{
/// <summary>
/// 创建并配置 MAUI 应用程序
/// </summary>
public static MauiApp CreateMauiApp()
{
ConfigurePlatformWebViewContainer();
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
@@ -73,17 +71,7 @@ public static partial class MauiProgram
// 注册嵌入式 Web 服务器(平台相关)
#if WINDOWS
// Windows 平台下嵌入式 WebServer 的启用策略:
// - 静态托管模式(IsUsingStatic=true):由 MAUI 内置 WebServer 提供 wwwroot 与本地 API。
// - 开发三件套模式(IsUsingStatic=false,前端走 Vite):API 由独立 Host(5173) 提供,避免在 MAUI 内启动 WebServer 导致注入覆盖前端代理配置。
if (appSettings.WebServer.IsUsingStatic)
{
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
}
else
{
builder.Services.AddSingleton<IEmbeddedWebServerService, NoopEmbeddedWebServerService>();
}
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
#elif ANDROID
builder.Services.AddSingleton<IEmbeddedWebServerService, MobileEmbeddedWebServerService>();
#else
@@ -101,9 +89,8 @@ public static partial class MauiProgram
{
try
{
// 优先启动内嵌 WebServer,确保 WebView 加载时不再 ERR_CONNECTION_REFUSED。
await StartWebServer(app.Services);
InitializeDatabase(app.Services, connectionString);
await StartWebServer(app.Services);
}
catch (Exception ex)
{
@@ -114,8 +101,6 @@ public static partial class MauiProgram
return app;
}
static partial void ConfigurePlatformWebViewContainer();
/// <summary>
/// 从 appsettings.json 加载配置
/// </summary>
-7
View File
@@ -29,13 +29,6 @@ public class WebServerSettings
[JsonPropertyName("ForEndUrl")]
public string ForEndUrl { get; set; } = "http://localhost:5174";
/// <summary>
/// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。
/// 空字符串表示未配置云同步。
/// </summary>
[JsonPropertyName("CloudSyncUrl")]
public string CloudSyncUrl { get; set; } = "";
}
public class HotKeyDefaultSettings
@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.hua.todo">
<application android:allowBackup="true" android:icon="@drawable/appicon" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config" android:label="To&amp;Do">
<provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" />
</application>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@drawable/appicon" android:roundIcon="@drawable/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,7 +1,6 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.View;
namespace Hua.Todo.Maui;
@@ -19,60 +18,8 @@ public class MainActivity : MauiAppCompatActivity
{
base.OnCreate(savedInstanceState);
ConfigureSystemBars();
#if DEBUG
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
#endif
}
/// <summary>
/// Activity 恢复时回调。
/// 某些设备/启动链路会在恢复阶段重置系统栏样式,这里再次应用。
/// </summary>
protected override void OnResume()
{
base.OnResume();
ConfigureSystemBars();
}
/// <summary>
/// 窗口焦点变化回调。
/// 焦点切换后部分系统会重新应用主题色,需再次修正。
/// </summary>
/// <param name="hasFocus">是否获得焦点。</param>
public override void OnWindowFocusChanged(bool hasFocus)
{
base.OnWindowFocusChanged(hasFocus);
if (hasFocus)
{
ConfigureSystemBars();
}
}
/// <summary>
/// 配置 Android 系统栏样式,避免默认紫色状态栏与页面视觉割裂。
/// </summary>
private void ConfigureSystemBars()
{
if (Window is null)
{
return;
}
// 使用接近页面背景的浅色系统栏,弱化顶部挖孔区域的突兀感。
var systemBarColor = global::Android.Graphics.Color.ParseColor("#F8FAFC");
Window.SetStatusBarColor(systemBarColor);
Window.SetNavigationBarColor(systemBarColor);
// 保持内容在系统栏下方,避免顶部控件贴到状态栏区域。
WindowCompat.SetDecorFitsSystemWindows(Window, true);
var insetsController = WindowCompat.GetInsetsController(Window, Window.DecorView);
if (insetsController is not null)
{
insetsController.AppearanceLightStatusBars = true;
insetsController.AppearanceLightNavigationBars = true;
}
}
}
@@ -55,9 +55,7 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
if (_listener != null) return Task.CompletedTask;
_cts = new CancellationTokenSource();
// 使用 IPAddress.Any 而非 Loopback,避免 localhost 解析到 IPv6 [::1] 时连接被拒绝。
// 同时确保在 Android 模拟器/设备上 localhost 能正确连通。
_listener = new TcpListener(IPAddress.Any, _appSettings.WebServer.Port);
_listener = new TcpListener(IPAddress.Loopback, _appSettings.WebServer.Port);
_listener.Start();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
@@ -259,42 +257,12 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
return;
}
Console.WriteLine($"[WebServer] Serving static: {path} -> {assetPath}");
if (!TryOpenAsset(assetPath, out var assetStream))
{
// 如果 wwwroot/path 找不到,尝试直接加载 path(应对路径扁平化)
var fallbackAssetPath = path.TrimStart('/');
if (!string.IsNullOrEmpty(fallbackAssetPath) && !fallbackAssetPath.StartsWith("wwwroot", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"[WebServer] {assetPath} not found, trying fallback path: {fallbackAssetPath}");
if (TryOpenAsset(fallbackAssetPath, out assetStream))
{
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, GetContentType(path), token);
}
return;
}
}
if (!Path.HasExtension(path))
{
var fallbackHtmlPath = "wwwroot/index.html";
Console.WriteLine($"[WebServer] {assetPath} not found, trying SPA fallback: {fallbackHtmlPath}");
if (TryOpenAsset(fallbackHtmlPath, out assetStream))
{
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, "text/html; charset=utf-8", token);
}
return;
}
// 再次尝试不带 wwwroot 的 index.html
fallbackHtmlPath = "index.html";
Console.WriteLine($"[WebServer] {assetPath} not found, trying SPA fallback: {fallbackHtmlPath}");
if (TryOpenAsset(fallbackHtmlPath, out assetStream))
assetPath = "wwwroot/index.html";
if (TryOpenAsset(assetPath, out assetStream))
{
await using (assetStream)
{
@@ -304,7 +272,6 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
}
}
Console.WriteLine($"[WebServer] 404 Not Found: {assetPath}");
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
return;
}
@@ -319,43 +286,11 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
{
try
{
// Android AssetManager.Open() 路径不能以 / 开头,且必须使用正斜杠
var normalizedPath = assetPath.TrimStart('/').Replace('\\', '/');
stream = global::Android.App.Application.Context.Assets.Open(normalizedPath);
stream = global::Android.App.Application.Context.Assets.Open(assetPath);
return true;
}
catch (Exception ex)
catch
{
Console.WriteLine($"[WebServer] Failed to open asset '{assetPath}': {ex.Message}");
// 只在 wwwroot/index.html 失败时尝试列出文件,帮助排查打包结构
if (assetPath.EndsWith("index.html", StringComparison.OrdinalIgnoreCase))
{
try
{
// 列出 root assets 和 wwwroot 目录,帮助排查打包结构
var rootAssets = global::Android.App.Application.Context.Assets.List("");
if (rootAssets != null)
{
Console.WriteLine($"[WebServer] Root Assets: {string.Join(", ", rootAssets)}");
}
var assets = global::Android.App.Application.Context.Assets.List("wwwroot");
if (assets != null && assets.Length > 0)
{
Console.WriteLine($"[WebServer] Assets in 'wwwroot/': {string.Join(", ", assets)}");
}
else
{
Console.WriteLine("[WebServer] 'wwwroot/' directory is empty or not found in assets.");
}
}
catch (Exception listEx)
{
Console.WriteLine($"[WebServer] Failed to list assets: {listEx.Message}");
}
}
stream = Stream.Null;
return false;
}
@@ -488,8 +423,6 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
var requestLine = await reader.ReadLineAsync(token);
if (string.IsNullOrWhiteSpace(requestLine)) throw new InvalidOperationException("empty request line");
Console.WriteLine($"[WebServer] Raw Request Line: {requestLine}");
var parts = requestLine.Split(' ');
if (parts.Length < 2) throw new InvalidOperationException("invalid request line");
@@ -497,19 +430,11 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
var target = parts[1].Trim();
string path;
if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri) &&
(absoluteUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
absoluteUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)))
{
// WebView 有时会发送绝对 URL(例如 GET http://localhost:5057/ HTTP/1.1
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri))
{
path = absoluteUri.AbsolutePath;
Console.WriteLine($"[WebServer] Parsed path from absolute URI: {path}");
}
else
{
path = target;
}
path = absoluteUri.AbsolutePath;
}
else if (target.StartsWith("//", StringComparison.Ordinal) &&
Uri.TryCreate("http:" + target, UriKind.Absolute, out var authorityUri))
@@ -526,8 +451,6 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
if (string.IsNullOrEmpty(path)) path = "/";
if (!path.StartsWith("/", StringComparison.Ordinal)) path = "/" + path;
Console.WriteLine($"[WebServer] Final Resolved Path: {path}");
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? line;
while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(token)))
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#F8FAFC</color>
<color name="colorPrimaryDark">#E2E8F0</color>
<color name="colorAccent">#2563EB</color>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>
@@ -1,6 +1,4 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Handlers;
namespace Hua.Todo.Maui.Views
{
@@ -32,67 +30,5 @@ namespace Hua.Todo.Maui.Views
var windowService = new Platforms.Windows.WindowsWindowService();
windowService.MinimizeWindow(window);
}
/// <summary>
/// 配置 WebView 滚动条行为。
/// </summary>
partial void ConfigureWebViewScrollBars()
{
// 使用 JavaScript 注入的方式禁用滚动条
MainWebView.EvaluateJavaScriptAsync(@"
// 禁用页面垂直滚动条
document.body.style.overflowY = 'hidden';
// 禁用整个文档的滚动
document.documentElement.style.overflowY = 'hidden';
");
}
partial void PlatformPrepareWebViewContainer()
{
if (Platforms.Windows.WebView2RuntimeDetector.IsRuntimeInstalled(out _))
{
return;
}
_isWebViewContainerReady = false;
var downloadUrl = "https://developer.microsoft.com/microsoft-edge/webview2/";
var content = new VerticalStackLayout
{
Padding = new Thickness(20),
Spacing = 12,
Children =
{
new Label
{
Text = "检测到系统未安装 WebView2 Runtime,无法加载主界面。",
FontSize = 16
},
new Label
{
Text = "请安装 Microsoft Edge WebView2 RuntimeEvergreen),安装完成后重新打开应用。",
Opacity = 0.85
},
new Button
{
Text = "打开下载页面",
Command = new Command(async () =>
{
await Launcher.Default.OpenAsync(downloadUrl);
})
},
new Button
{
Text = "退出应用",
Command = new Command(() =>
{
Microsoft.Maui.Controls.Application.Current?.Quit();
})
}
}
};
Content = new ScrollView { Content = content };
}
}
}
+1 -22
View File
@@ -62,25 +62,6 @@ cd Hua.Todo.Maui
dotnet build -f net10.0-windows10.0.19041.0
dotnet run -f net10.0-windows10.0.19041.0
```
Windows Debug 编译下,MAUI 内嵌 WebServer 会提供接口文档:
- Swagger UI`{HostUrl}/swagger`
- OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`
其中 `{HostUrl}` 来自 `appsettings.json: WebServer.HostUrl`(默认 `http://localhost:5057`)。
按默认配置,对应地址为:
- Swagger UI`http://localhost:5057/swagger`
- OpenAPI JSON`http://localhost:5057/swagger/v1/swagger.json`
#### Windows(三件套热更新:MAUI + Vite + Host
该模式用于开发阶段获得最佳热更新体验:
- `Hua.Todo.Host`:提供 API`http://localhost:5173`
- `Hua.Todo.Web`Vite dev server`http://localhost:5174`),并将 `/api` 代理到 5173
- `Hua.Todo.Maui`WebView 加载 5174
在该模式下,Swagger UI 地址为:`http://localhost:5173/swagger`(仅 `ASPNETCORE_ENVIRONMENT=Development` 时启用)。
```powershell
.\start-dev.ps1
```
然后在 Visual Studio 中启动 `Hua.Todo.Maui`F5)。
#### macOS
```bash
@@ -153,9 +134,7 @@ dotnet run -f net10.0-android
1. **macOS 权限**: 首次运行时需要在系统设置中授予辅助功能权限
2. **Windows UAC**: 某些情况下可能需要管理员权限
3. **移动端限制**: 移动端不支持真正的全局快捷键,使用通知快捷方式替代
4. **WebView(开发)**: 三件套模式下需要确保 `Hua.Todo.Host``http://localhost:5173` 运行,同时 `Hua.Todo.Web` `http://localhost:5174` 运行
5. **Windows WebView2 数据目录**: WebView2 的缓存/存储写入 `%LocalAppData%\Hua.Todo\WebView2`,避免在安装目录生成 `*.WebView2` 文件夹
6. **Windows WebView2 Runtime**: 依赖系统安装的 Microsoft Edge WebView2 Runtime;若缺失应用会提示下载安装
4. **WebView**: 确保 Hua.Todo.Api 服务`http://localhost:5173` 运行
## 后续计划
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Hua.Todo.Maui.Services;
public static class AppMetadata
{
private const string AppNameText = "Hua.Todo";
private const string AppNameText = "\u5F85\u529E\u4E8B\u9879";
public static string AppName => AppNameText;
@@ -7,9 +7,7 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using System.Text.Json;
using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.DynamicApi.Swagger;
using Hua.Todo.Maui.Models;
using AppSettings = Hua.Todo.Maui.Models.AppSettings;
@@ -67,23 +65,10 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
});
builder.Services.AddEndpointsApiExplorer();
#if DEBUG
builder.Services.AddSwaggerGen(options =>
{
options.DocumentFilter<DynamicApiSwaggerDocumentFilter>();
});
#endif
// 注册应用逻辑服务
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
// 注册云同步反向代理(URL 管理 + 转发用 HttpClient
builder.Services.AddCloudSyncProxy();
// 注册 URL 持久化
builder.Services.AddSingleton(new CloudSyncProxySettingsPersistence(
Path.Combine(AppContext.BaseDirectory, "appsettings.json")));
// 配置跨域策略
builder.Services.AddCors(options =>
{
@@ -97,18 +82,6 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
var app = builder.Build();
// 从 appsettings 初始化 CloudSyncUrl
var proxySettings = app.Services.GetRequiredService<CloudSyncProxySettings>();
if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl))
{
proxySettings.SetUrl(_appSettings.WebServer.CloudSyncUrl);
}
#if DEBUG
app.UseSwagger();
app.UseSwaggerUI();
#endif
// 如果配置为使用静态文件(前端托管),则配置静态文件服务
if (_appSettings.WebServer.IsUsingStatic)
{
@@ -116,11 +89,9 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
}
app.UseCors("AllowAll");
app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权)
app.UseAuthorization();
app.UseDynamicApi();
app.MapControllers();
app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings
_webApp = app;
@@ -164,18 +135,7 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
if (context.Request.Path.HasValue)
{
var path = context.Request.Path.Value;
// Swagger 仅在 DEBUG 下启用;Release 下不应把 /swagger 当作“后端专用路径”排除,
// 否则访问 /swagger 会直接 404 而不会回落到 SPA/index.html)。
#if DEBUG
var isSwaggerPath = path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase);
#else
var isSwaggerPath = false;
#endif
if (path != "/"
&& !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase)
&& !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase)
&& !isSwaggerPath)
if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
{
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
-17
View File
@@ -12,7 +12,6 @@ namespace Hua.Todo.Maui.Views
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService? _webServer;
private bool _isWebViewContainerReady = true;
/// <summary>
/// 创建 <see cref="MainPage"/>。
@@ -25,12 +24,6 @@ namespace Hua.Todo.Maui.Views
_appSettings = appSettings;
_webServer = webServer;
PlatformPrepareWebViewContainer();
if (!_isWebViewContainerReady)
{
return;
}
SetupWebViewSource();
SetupWebViewCommunication();
SetupKeyboardHandler();
@@ -119,17 +112,9 @@ namespace Hua.Todo.Maui.Views
}
});
");
// 配置 WebView 滚动条行为(平台特定实现)
ConfigureWebViewScrollBars();
};
}
/// <summary>
/// 配置 WebView 滚动条行为(平台特定实现)。
/// </summary>
partial void ConfigureWebViewScrollBars();
/// <summary>
/// 规格化 URL(针对 Android 模拟器处理 localhost)。
/// Android 模拟器中 localhost 指向模拟器自身,需要替换为 10.0.2.2 才能访问宿主机服务。
@@ -156,12 +141,10 @@ namespace Hua.Todo.Maui.Views
/// 平台特定的键盘处理器初始化。
/// </summary>
partial void PlatformSetupKeyboardHandler();
/// <summary>
/// 平台特定的 Esc 键处理逻辑。
/// </summary>
/// <param name="window">当前窗口。</param>
partial void PlatformOnEscKeyPressed(Window window);
partial void PlatformPrepareWebViewContainer();
}
}
+2 -3
View File
@@ -1,11 +1,10 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"IsUsingStatic": true,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174",
"CloudSyncUrl": ""
"ForEndUrl": "http://localhost:5174"
},
"Development": {
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+4 -11
View File
@@ -1,8 +1,8 @@
#define MyAppName "Hua.Todo"
#define MyAppVersion "1.2.7"
#define MyAppVersion "1.1.4"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.Maui.exe"
#define MyAppExeName "Hua.Todo.exe"
[Setup]
; 1. 改用更快压缩(优先速度)
@@ -34,7 +34,6 @@ PrivilegesRequired=lowest
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
SetupIconFile=icon.ico
UninstallDisplayIcon={app}\icon.ico
SolidCompression=no
WizardStyle=modern
@@ -48,15 +47,9 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
Source: "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 注意: 请勿在任何共享系统文件上使用“Flags: ignoreversion”
[InstallDelete]
Type: filesandordirs; Name: "{app}\{#MyAppExeName}.WebView2"
[UninstallDelete]
Type: filesandordirs; Name: "{app}\{#MyAppExeName}.WebView2"
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\icon.ico"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico"
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+2 -3
View File
@@ -1,13 +1,12 @@
{
"name": "Hua.Todo-Web",
"name": "Hua.Todo-web",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Hua.Todo-Web",
"name": "Hua.Todo-web",
"version": "0.0.0",
"license": "AGPL-3.0",
"dependencies": {
"axios": "^1.13.6",
"pinia": "^3.0.4",
+1 -2
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,7 +7,6 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build:maui": "vue-tsc -b && vite build --mode maui",
"preview": "vite preview"
},
"dependencies": {
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 9.3 KiB

-2
View File
@@ -2,7 +2,6 @@
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');
@@ -27,7 +26,6 @@ onMounted(() => {
<div class="app">
<TaskList />
<HotKeySettingsDialog />
<CloudSyncSettingsDialog />
<!-- Global Toast Notification -->
<Transition name="toast">
-7
View File
@@ -48,13 +48,6 @@ apiClient.interceptors.response.use(
console.error('API Error:', error);
let errorMessage = '网络错误,请稍后再试';
const status = error?.response?.status as number | undefined;
if (status === 502 || status === 503 || status === 504) {
errorMessage = '后端服务不可达:请确认 Hua.Todo.Host 已启动(http://localhost:5173';
showNotification(errorMessage, 'error');
return Promise.reject(error);
}
if (error.response && error.response.data) {
const data = error.response.data;
const errors = data.errors || data.Errors;
+11 -31
View File
@@ -2,7 +2,6 @@ import apiClient from './client';
import type { Task, CreateTaskDto, UpdateTaskDto, ApiResponse } from '../types/task';
import LocalStorageService from '../services/localStorageService';
import { normalizeTask, normalizeTasks } from '../services/taskNormalizer';
import { generateGuid } from '../utils/guid';
export const taskApi = {
/**
@@ -13,7 +12,6 @@ export const taskApi = {
async getTasks(completed?: boolean): Promise<ApiResponse<Task[]>> {
if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
LocalStorageService.syncTaskIdCounter(localTasks);
return {
success: true,
data: completed !== undefined
@@ -52,8 +50,6 @@ export const taskApi = {
syncStatus.isOnline = true;
syncStatus.pendingChanges = 0;
LocalStorageService.saveSyncStatus(syncStatus);
// 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突
LocalStorageService.syncTaskIdCounter(apiResponse.data);
}
return apiResponse;
@@ -64,7 +60,7 @@ export const taskApi = {
* @param id 任务 ID
* @returns 包含任务对象或 null 的响应对象
*/
async getTask(id: string): Promise<ApiResponse<Task | null>> {
async getTask(id: number): Promise<ApiResponse<Task | null>> {
if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
const task = localTasks.find(t => t.id === id);
@@ -103,31 +99,19 @@ export const taskApi = {
* @returns 包含新创建任务的响应对象
*/
async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> {
const now = new Date().toISOString();
// 离线时从本地已有任务生成 code
const localTasks = LocalStorageService.loadTasks();
const maxCode = localTasks.reduce((max, t) => {
const n = parseInt(t.code ?? '0', 10);
return isNaN(n) ? max : Math.max(max, n);
}, 0);
const newTask: Task = {
id: generateGuid(),
id: Date.now(),
title: dto.title,
priority: dto.priority,
isCompleted: false,
code: String(maxCode + 1),
creationTime: now,
creatorId: null,
lastModificationTime: now,
lastModifierId: null,
isDeleted: false,
deletionTime: null,
deleterId: null,
parentTaskId: dto.parentTaskId ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
parentTaskId: dto.parentTaskId,
subTasks: []
};
if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
localTasks.unshift(newTask);
LocalStorageService.saveTasks(localTasks);
@@ -167,8 +151,6 @@ export const taskApi = {
localTasks.unshift(apiResponse.data);
}
LocalStorageService.saveTasks(localTasks);
// 用后端返回的真实 ID 同步计数器,保证后续新建任务 ID 始终大于已有任务
LocalStorageService.syncTaskIdCounter(localTasks);
}
return apiResponse;
@@ -180,7 +162,7 @@ export const taskApi = {
* @param dto 更新任务的数据传输对象
* @returns 包含更新后任务的响应对象
*/
async updateTask(id: string, dto: UpdateTaskDto): Promise<ApiResponse<Task>> {
async updateTask(id: number, dto: UpdateTaskDto): Promise<ApiResponse<Task>> {
const updateDto = dto;
if (!LocalStorageService.isOnline()) {
@@ -194,7 +176,7 @@ export const taskApi = {
if (dto.priority !== undefined) {
localTasks[taskIndex].priority = dto.priority;
}
localTasks[taskIndex].lastModificationTime = new Date().toISOString();
localTasks[taskIndex].updatedAt = new Date().toISOString();
LocalStorageService.saveTasks(localTasks);
const syncStatus = LocalStorageService.loadSyncStatus();
@@ -247,14 +229,14 @@ export const taskApi = {
* @param id 任务 ID
* @returns 包含更新后任务的响应对象
*/
async toggleComplete(id: string): Promise<ApiResponse<Task>> {
async toggleComplete(id: number): Promise<ApiResponse<Task>> {
if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id);
if (taskIndex !== -1) {
localTasks[taskIndex].isCompleted = !localTasks[taskIndex].isCompleted;
localTasks[taskIndex].lastModificationTime = new Date().toISOString();
localTasks[taskIndex].updatedAt = new Date().toISOString();
LocalStorageService.saveTasks(localTasks);
const syncStatus = LocalStorageService.loadSyncStatus();
@@ -307,7 +289,7 @@ export const taskApi = {
* @param id 任务 ID
* @returns 包含操作结果的响应对象
*/
async deleteTask(id: string): Promise<ApiResponse<object>> {
async deleteTask(id: number): Promise<ApiResponse<object>> {
if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id);
@@ -404,8 +386,6 @@ export const taskApi = {
syncStatus.isOnline = true;
syncStatus.pendingChanges = 0;
LocalStorageService.saveSyncStatus(syncStatus);
// 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突
LocalStorageService.syncTaskIdCounter(apiResponse.data);
return {
success: true,
+4 -9
View File
@@ -18,7 +18,7 @@
<div class="task-info">
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span class="task-id task-id--compact">#{{ task.code }}</span>
<span class="task-id task-id--compact">#{{ task.id }}</span>
<span class="task-priority task-priority--compact">{{ priorityShortText }}</span>
<span class="task-created">创建{{ createdAtText }}</span>
</div>
@@ -98,7 +98,7 @@ const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'updated', task: Task): void;
(e: 'deleted', id: string): void;
(e: 'deleted', id: number): void;
(e: 'subtask-created', task: Task): void;
}>();
@@ -145,7 +145,7 @@ const formatDateTime = (value: string): string => {
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
};
const createdAtText = computed(() => formatDateTime(props.task.creationTime));
const createdAtText = computed(() => formatDateTime(props.task.createdAt));
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
@@ -201,18 +201,13 @@ const handleSubTaskUpdated = (updatedTask: Task) => {
if (props.task.subTasks) {
const index = props.task.subTasks.findIndex(t => t.id === updatedTask.id);
if (index !== -1) {
// 保留现有子任务:API 返回的 DTO 是平面的(subTasks=[]),直接替换会丢失该任务已有的所有后代
const existing = props.task.subTasks[index];
if (existing.subTasks?.length && !updatedTask.subTasks?.length) {
updatedTask.subTasks = existing.subTasks;
}
props.task.subTasks[index] = updatedTask;
emit('updated', props.task);
}
}
};
const handleSubTaskDeleted = (subTaskId: string) => {
const handleSubTaskDeleted = (subTaskId: number) => {
if (props.task.subTasks) {
props.task.subTasks = props.task.subTasks.filter(t => t.id !== subTaskId);
emit('updated', props.task);
+19 -418
View File
@@ -31,29 +31,6 @@
<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"
@@ -68,24 +45,6 @@
</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>
<button
v-if="isCloudSyncEnabled && isCloudLoggedIn"
class="cloud-sync-btn sync-action-btn"
:disabled="isSyncing"
@click="syncNow"
title="立即同步:将本地变更推送到云端并拉取最新数据"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 0 0 4.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 0 1-15.357-2m15.357 2H15" />
</svg>
<span>{{ isSyncing ? '同步中...' : '同步' }}</span>
</button>
</div>
</div>
<div class="task-info">
@@ -93,9 +52,6 @@
<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">
@@ -119,9 +75,7 @@
</button>
</div>
<div v-if="filteredTasks.length === 0" class="empty">
{{ searchQuery.trim().length > 0 ? '无匹配任务' : '暂无任务' }}
</div>
<div v-if="filteredTasks.length === 0" class="empty">暂无任务</div>
<div v-else class="tasks-shell">
<div class="tasks-scroll">
@@ -141,10 +95,8 @@
<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';
@@ -159,19 +111,6 @@ 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 isSyncing = ref(false);
const showToast = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
@@ -200,75 +139,17 @@ 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);
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(() => false);
const cloudStatusLabel = computed(() => {
if (!isCloudSyncEnabled.value) return '';
if (!CloudSyncStorage.loadSettings().serverUrl) return '云同步:未配置';
if (!isCloudLoggedIn.value) return '云同步:未登录';
return '云同步:已登录';
if (showCompleted.value) {
// 当显示已完成时,返回所有根任务(不进行过滤,保持原始树结构)
return sortTasks(rootTasks);
} else {
// 当隐藏已完成时,递归过滤掉已完成的任务
const activeTasks = filterTasksByCompletion(rootTasks, false);
return sortTasks(activeTasks);
}
});
const sortTasks = (tasks: Task[]): Task[] => {
@@ -286,21 +167,21 @@ const sortTasks = (tasks: Task[]): Task[] => {
const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority];
comparison = priorityDiff;
if (comparison === 0) {
comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
break;
case 'completedAt': {
const aVal = a.isCompleted && a.lastModificationTime ? new Date(a.lastModificationTime).getTime() : 0;
const bVal = b.isCompleted && b.lastModificationTime ? new Date(b.lastModificationTime).getTime() : 0;
const aVal = a.isCompleted ? new Date(a.updatedAt).getTime() : 0;
const bVal = b.isCompleted ? new Date(b.updatedAt).getTime() : 0;
comparison = bVal - aVal;
if (comparison === 0) {
comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
break;
}
case 'createdAt':
default:
comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
break;
}
@@ -346,17 +227,6 @@ 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;
@@ -386,7 +256,6 @@ const createTask = async () => {
tasks.value.unshift(response.data);
newTaskTitle.value = '';
newTaskPriority.value = 1;
lastSyncTime.value = Date.now();
}
} catch (error) {
console.error('Failed to create task:', error);
@@ -396,16 +265,11 @@ const createTask = async () => {
const handleTaskUpdated = (updatedTask: Task) => {
updateTaskInTree(tasks.value, updatedTask);
lastSyncTime.value = Date.now();
};
const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === updatedTask.id) {
// 保留现有子任务:API 返回的 DTO 是平面的(subTasks=[]),直接替换会丢失该任务已有的所有后代
if (tasks[i].subTasks?.length && !updatedTask.subTasks?.length) {
updatedTask.subTasks = tasks[i].subTasks;
}
tasks[i] = updatedTask;
return true;
}
@@ -418,12 +282,11 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
return false;
};
const handleTaskDeleted = (taskId: string) => {
const handleTaskDeleted = (taskId: number) => {
deleteTaskFromTree(tasks.value, taskId);
lastSyncTime.value = Date.now();
};
const deleteTaskFromTree = (tasks: Task[], taskId: string): boolean => {
const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === taskId) {
tasks.splice(i, 1);
@@ -438,23 +301,8 @@ const deleteTaskFromTree = (tasks: Task[], taskId: string): boolean => {
return false;
};
const handleSubTaskCreated = async (subTask: Task) => {
try {
// 直接从 API 重新加载完整任务树,保证所有层级的嵌套关系正确且响应式链路完整。
// 深层嵌套(3+层)时,原地修改树(addSubTaskToTree)无法可靠触发 TaskItem 的 re-render
// 因为 filteredTasks 计算属性中的浅拷贝({ ...task })在替换 subTasks 引用时会断开追踪。
const response = await taskApi.getTasks();
if (response.success && response.data) {
tasks.value = response.data;
} else {
console.warn('[handleSubTaskCreated] API 返回异常,尝试就地添加');
addSubTaskToTree(tasks.value, subTask);
}
} catch (error) {
console.error('[handleSubTaskCreated] 加载任务失败,使用就地添加:', error);
addSubTaskToTree(tasks.value, subTask);
}
lastSyncTime.value = Date.now();
const handleSubTaskCreated = (subTask: Task) => {
addSubTaskToTree(tasks.value, subTask);
};
const addSubTaskToTree = (tasks: Task[], subTask: Task): boolean => {
@@ -463,8 +311,7 @@ const addSubTaskToTree = (tasks: Task[], subTask: Task): boolean => {
if (!tasks[i].subTasks) {
tasks[i].subTasks = [];
}
// 使用新数组引用替换而非 push,确保 Vue 3 响应式系统无论嵌套多深都能检测到变更
tasks[i].subTasks = [...tasks[i].subTasks, subTask];
tasks[i].subTasks.push(subTask);
return true;
}
if (tasks[i].subTasks && tasks[i].subTasks.length > 0) {
@@ -473,7 +320,6 @@ const addSubTaskToTree = (tasks: Task[], subTask: Task): boolean => {
}
}
}
console.warn(`[addSubTaskToTree] 未找到父任务 id="${subTask.parentTaskId}",子任务 id="${subTask.id}" 未能加入树中`);
return false;
};
@@ -488,132 +334,16 @@ 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();
};
/**
* 立即同步:将本地任务推送到云端并获取云端最新数据。
* 策略:服务端为单一真相源,但合并而非盲目替换 ——
* 1. 上传所有本地任务(upserts)
* 2. 服务端按 LWW 合并并返回全量
* 3. 以服务端列表为准,同时保留服务端未返回的本地任务(防止数据丢失)
*/
const syncNow = async () => {
if (isSyncing.value) return;
isSyncing.value = true;
try {
const localTasks = tasks.value.length > 0
? tasks.value
: LocalStorageService.loadTasks();
// 上传统计所有本地任务 ID,用于后续合并校验
const localTaskIds = new Set<string>();
const collectIds = (items: Task[]) => {
for (const t of items) {
if (t.id) localTaskIds.add(String(t.id));
if (t.subTasks?.length) collectIds(t.subTasks);
}
};
collectIds(localTasks);
const { tasks: cloudTasks, serverTimeUtc } = await cloudSyncApi.syncTasks(localTasks);
// 构建云端任务 ID 索引
const cloudTaskMap = new Map<string, Task>();
const collectCloudIds = (items: Task[]) => {
for (const t of items) {
if (t.id) cloudTaskMap.set(String(t.id), t);
if (t.subTasks?.length) collectCloudIds(t.subTasks);
}
};
collectCloudIds(cloudTasks);
// 合并策略:云端已有的任务以云端为准;云端没有的本地任务保留(附时间戳确保下次能被接受)
const mergedTasks: Task[] = [...cloudTasks];
const nowIso = new Date().toISOString();
// 递归收集云端缺失的本地任务,正确构建子任务树结构
const collectMissingLocals = (items: Task[]): Task[] => {
const result: Task[] = [];
for (const t of items) {
const id = String(t.id);
if (cloudTaskMap.has(id)) {
// 任务已在云端返回中 → 跳过(云端版本已在 mergedTasks 中,含正确子树)
continue;
}
// 任务不在云端返回中 → 保留本地版本,递归处理子任务
const preserved: Task = {
...t,
lastModificationTime: t.lastModificationTime || nowIso,
};
if (t.subTasks?.length) {
preserved.subTasks = collectMissingLocals(t.subTasks);
}
result.push(preserved);
console.warn(`[CloudSync] 任务 ${id} ("${t.title}") 未在云端返回列表中找到,已保留本地副本`);
}
return result;
};
const missingLocals = collectMissingLocals(localTasks);
mergedTasks.push(...missingLocals);
tasks.value = mergedTasks;
LocalStorageService.saveTasks(mergedTasks);
const syncStatus = LocalStorageService.loadSyncStatus();
const serverTimestamp = serverTimeUtc ? new Date(serverTimeUtc).getTime() : Date.now();
syncStatus.lastSyncTime = serverTimestamp;
syncStatus.isOnline = true;
syncStatus.pendingChanges = 0;
LocalStorageService.saveSyncStatus(syncStatus);
lastSyncTime.value = syncStatus.lastSyncTime;
pendingChanges.value = 0;
LocalStorageService.syncTaskIdCounter(mergedTasks);
showToast('同步成功', 'success');
} catch (error) {
console.error('Sync failed:', error);
showToast('同步失败,请检查网络或登录状态');
} finally {
isSyncing.value = false;
}
};
/**
* 处理对话框触发的同步请求。
*/
const handleCloudSyncTasksRequested = () => {
refreshCloudModeState();
syncNow();
};
onMounted(() => {
refreshCloudModeState();
loadTasks();
handleOnlineStatus();
window.addEventListener('online', handleOnlineStatus);
window.addEventListener('offline', handleOnlineStatus);
window.addEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.addEventListener('cloudSyncTasksRequested', handleCloudSyncTasksRequested as EventListener);
});
onUnmounted(() => {
window.removeEventListener('online', handleOnlineStatus);
window.removeEventListener('offline', handleOnlineStatus);
window.removeEventListener('cloudSyncStateChanged', handleCloudSyncStateChanged as EventListener);
window.removeEventListener('cloudSyncTasksRequested', handleCloudSyncTasksRequested as EventListener);
});
</script>
@@ -853,61 +583,6 @@ 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);
@@ -992,70 +667,6 @@ 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;
}
.sync-action-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
box-shadow: 0 2px 8px -2px rgba(16, 185, 129, 0.35);
}
.sync-action-btn:hover {
box-shadow: 0 4px 12px -2px rgba(16, 185, 129, 0.45);
}
.sync-action-btn:disabled {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
box-shadow: none;
cursor: not-allowed;
opacity: 0.7;
transform: none;
}
.cloud-readonly-banner {
margin-bottom: 8px;
padding: 10px 12px;
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;
@@ -1156,11 +767,6 @@ 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);
@@ -1282,11 +888,6 @@ 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);
@@ -3,22 +3,6 @@ import { normalizeTasks } from './taskNormalizer';
const STORAGE_KEY = 'Hua.Todo_tasks';
const SYNC_STATUS_KEY = 'Hua.Todo_sync_status';
const NEXT_TASK_ID_KEY = 'Hua.Todo_next_task_id';
/**
* 内存存储备份(当禁止落盘时使用)。
*/
const memoryStore = {
tasks: [] as Task[],
syncStatus: null as SyncStatus | null,
nextTaskId: 1,
};
/**
* 当前是否允许落盘。
* 由外部(如 CloudSyncSettingsDialog)根据服务端策略动态设置。
*/
let allowPersist = true;
export interface SyncStatus {
lastSyncTime: number;
@@ -27,55 +11,18 @@ export interface SyncStatus {
}
export class LocalStorageService {
/**
* 设置落盘策略。若从允许切换到禁止,则清空已落盘数据。
*/
static setAllowPersist(allowed: boolean): void {
const previous = allowPersist;
allowPersist = allowed;
if (previous && !allowed) {
this.clearTasks();
localStorage.removeItem(SYNC_STATUS_KEY);
localStorage.removeItem(NEXT_TASK_ID_KEY);
memoryStore.nextTaskId = 1;
console.log('CloudSync: Security policy changed to "No Persist", local data cleared.');
} else if (!previous && allowed) {
// 从禁止切回允许时,将内存中累计的 nextTaskId 写回 localStorage
if (memoryStore.nextTaskId > 1) {
try {
localStorage.setItem(NEXT_TASK_ID_KEY, String(memoryStore.nextTaskId));
} catch {
// 忽略落盘错误
}
}
}
}
static saveTasks(tasks: Task[]): void {
const normalized = normalizeTasks(tasks);
if (!allowPersist) {
memoryStore.tasks = normalized;
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized));
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeTasks(tasks)));
} catch (error) {
console.error('Failed to save tasks to localStorage:', error);
}
}
static loadTasks(): Task[] {
if (!allowPersist) {
return memoryStore.tasks;
}
try {
const data = localStorage.getItem(STORAGE_KEY);
const tasks = data ? normalizeTasks(JSON.parse(data)) : [];
// 每次读取时同步一次 nextTaskId 计数器,保证后续生成的新 ID 不会与现有冲突
this.syncTaskIdCounter(tasks);
return tasks;
return data ? normalizeTasks(JSON.parse(data)) : [];
} catch (error) {
console.error('Failed to load tasks from localStorage:', error);
return [];
@@ -83,7 +30,6 @@ export class LocalStorageService {
}
static clearTasks(): void {
memoryStore.tasks = [];
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
@@ -92,11 +38,6 @@ export class LocalStorageService {
}
static saveSyncStatus(status: SyncStatus): void {
if (!allowPersist) {
memoryStore.syncStatus = status;
return;
}
try {
localStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status));
} catch (error) {
@@ -105,10 +46,6 @@ export class LocalStorageService {
}
static loadSyncStatus(): SyncStatus {
if (!allowPersist && memoryStore.syncStatus) {
return memoryStore.syncStatus;
}
try {
const data = localStorage.getItem(SYNC_STATUS_KEY);
return data ? JSON.parse(data) : {
@@ -129,79 +66,6 @@ export class LocalStorageService {
static isOnline(): boolean {
return navigator.onLine;
}
/**
* 获取下一个可用的任务 ID(自增,保证全局唯一且递增)。
* 读取当前值后立即自增并持久化。
* @returns 分配给新任务的 ID
*/
static getNextTaskId(): number {
let current: number;
if (!allowPersist) {
current = memoryStore.nextTaskId;
memoryStore.nextTaskId = current + 1;
return current;
}
try {
const raw = localStorage.getItem(NEXT_TASK_ID_KEY);
current = raw ? Number(raw) : 0;
if (!Number.isFinite(current) || current < 1) current = 1;
} catch {
current = 1;
}
const next = current + 1;
try {
localStorage.setItem(NEXT_TASK_ID_KEY, String(next));
} catch {
// 忽略落盘错误,下次将从 1 开始;运行时至少保证返回值递增
}
return current;
}
/**
* 根据现有任务集合同步 nextTaskId 计数器,确保它大于所有已存在的任务 ID。
* 用于从后端加载任务后或初始化本地数据后防止 ID 冲突。
* @param tasks 当前已知的任务列表(可以包含子任务,会递归遍历)
*/
static syncTaskIdCounter(tasks: Task[]): void {
if (!tasks || tasks.length === 0) return;
let maxId = 0;
const walk = (list: Task[]): void => {
for (const t of list) {
if (typeof t.id === 'number' && Number.isFinite(t.id) && t.id > maxId) {
maxId = t.id;
}
if (t.subTasks && t.subTasks.length > 0) {
walk(t.subTasks);
}
}
};
walk(tasks);
if (maxId < 1) return;
const target = maxId + 1;
if (!allowPersist) {
if (memoryStore.nextTaskId <= maxId) {
memoryStore.nextTaskId = target;
}
return;
}
try {
const raw = localStorage.getItem(NEXT_TASK_ID_KEY);
const current = raw ? Number(raw) : 0;
if (!Number.isFinite(current) || current < target) {
localStorage.setItem(NEXT_TASK_ID_KEY, String(target));
}
} catch {
// 忽略落盘错误
}
}
}
export default LocalStorageService;
+12 -11
View File
@@ -26,24 +26,25 @@ const toTaskPriority = (value: unknown): TaskPriority => {
return 1;
};
const toIsoString = (value: unknown): string => {
if (typeof value === 'string' && value.trim()) return value;
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
return new Date().toISOString();
};
export const normalizeTask = (task: any): Task => {
const normalized: any = { ...(task ?? {}) };
// id 现在是 Guid 字符串,不再进行类型转换
// 保留原始 id 即可(可能是 string 或 number,取决于数据来源)
if (typeof normalized.id === 'string' && /^\d+$/.test(normalized.id)) {
normalized.id = Number(normalized.id);
}
normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? '');
normalized.priority = toTaskPriority(normalized.priority);
normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted);
normalized.code = typeof normalized.code === 'string' ? normalized.code : '';
// 统一字段名:createdAt/updatedAt(旧) -> creationTime/lastModificationTime(新)
if (!normalized.creationTime) {
normalized.creationTime = normalized.createdAt || normalized.creationTime || new Date().toISOString();
}
if (!normalized.lastModificationTime) {
normalized.lastModificationTime = normalized.updatedAt || normalized.lastModificationTime || null;
}
normalized.createdAt = toIsoString(normalized.createdAt);
normalized.updatedAt = toIsoString(normalized.updatedAt);
if (normalized.parentTaskId === null) {
normalized.parentTaskId = undefined;
+6 -45
View File
@@ -1,65 +1,26 @@
export type TaskPriority = 0 | 1 | 2;
/**
* Todo 待办项(与 CloudTaskItem DTO 一一对应)。
* - 主键 id 为 Guid 序列化后的字符串
* - 包含 ABP 审计字段,用于云同步冲突判断
*/
export interface Task {
// === 业务字段 ===
/** 主键(Guid 序列化) */
id: string;
/** 标题 */
id: number;
title: string;
/** 优先级 */
priority: TaskPriority;
/** 是否完成 */
isCompleted: boolean;
/** 任务编号(由后端自动分配,用户级自增字符串) */
code: string;
/** 父任务 ID(Guid 类型,null 表示顶级任务) */
parentTaskId: string | null;
// === ABP 审计字段 ===
/** 创建时间(服务端分配) */
creationTime: string;
/** 创建人 ID */
creatorId: string | null;
/** 最后修改时间(用于 LWW 冲突判断) */
lastModificationTime: string | null;
/** 最后修改人 ID */
lastModifierId: string | null;
/** 软删除标记(前端本地过滤不展示) */
isDeleted: boolean;
/** 删除时间(不为 null 时表示已逻辑删除) */
deletionTime: string | null;
/** 删除人 ID */
deleterId: string | null;
// === 导航属性 ===
/** 子任务列表 */
createdAt: string;
updatedAt: string;
parentTaskId?: number;
subTasks: Task[];
}
/**
* 创建 Todo 待办项请求(客户端 → 服务端,新建时不带 id)
*/
export interface CreateTaskDto {
title: string;
priority: TaskPriority;
/** 父任务 IDGuid 字符串) */
parentTaskId?: string;
parentTaskId?: number;
}
/**
* 更新 Todo 待办项请求(客户端 → 服务端,必须带 id)
*/
export interface UpdateTaskDto {
/** 主键(Guid 字符串) */
id: string;
id: number;
title?: string;
priority?: TaskPriority;
isCompleted?: boolean;
}
export interface ApiResponse<T> {
+19 -73
View File
@@ -1,78 +1,24 @@
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { resolve } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig(({ mode }) => {
// Use `vite build --mode maui` to emit artifacts into the MAUI app's `wwwroot` for embedded hosting.
const outDir = mode === 'maui'
? resolve(__dirname, '../Hua.Todo.Maui/wwwroot')
: resolve(__dirname, 'dist')
// 主动加载 .env 文件,vite.config.ts 执行时 Vite 尚未自动加载
const env = loadEnv(mode, __dirname, '')
// Dev 模式下各端点的代理目标,严格从环境变量读取:
// - VITE_API_TARGET(必需): API 及云同步共用的代理目标
// - VITE_CLOUD_SYNC_URL(可选): 云同步独立远端地址,未设置时回退到 VITE_API_TARGET
// Build 模式下 proxy 配置不生效(无 dev server),仅 dev 模式使用
const apiTarget = env.VITE_API_TARGET
const cloudSyncTarget = env.VITE_CLOUD_SYNC_URL || apiTarget
if (!apiTarget) {
throw new Error(`[vite.config] 缺少必需的环境变量: VITE_API_TARGET。请检查 .env.${mode} 文件。`)
}
return {
plugins: [vue()],
build: {
outDir,
emptyOutDir: true,
},
define: {
// 非 MAUI 模式下静态替换 API 基地址;MAUI 模式下由宿主在运行时注入
...(mode !== 'maui' ? { 'window.__API_BASE_URL__': JSON.stringify('/api') } : {}),
// MAUI 模式标识:用于区分云同步请求的行为
'window.__IS_MAUI__': JSON.stringify(mode === 'maui'),
},
server: {
port: 5174,
proxy: {
// 本地 API 代理到后端
'/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
// 云同步端点代理到远端服务器
'/auth': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/tasks': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/sync': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/security': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
'/cloud-sync': {
target: cloudSyncTarget,
changeOrigin: true,
secure: false,
},
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,
},
},
}
},
})