From 28de5d614326b901add0575d9226dd858406b171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 5 Aug 2026 22:15:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E7=88=B6=E5=AD=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=94=AF=E6=8C=81(=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E2=86=92=E5=90=8E=E7=AB=AF=E2=86=92=E5=89=8D=E7=AB=AF=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF,=E4=BC=9A=E8=AF=9D=E5=89=8D=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=E6=94=B6=E5=B0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具 - 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板 - 设计文档: 父子任务支持设计-2026-08-04 --- crates/df-nodes/src/task_advance_node.rs | 100 ++++ .../父子任务支持设计-2026-08-04.md | 215 +++++++++ src-tauri/src/commands/ai/tools/task.rs | 7 +- src-tauri/src/commands/ai/tools/task_graph.rs | 48 +- src-tauri/src/commands/task.rs | 198 +++----- src/api/task.ts | 16 +- src/i18n/en/taskDetail.ts | 8 + src/i18n/en/tasks.ts | 11 + src/i18n/zh-CN/taskDetail.ts | 8 + src/i18n/zh-CN/tasks.ts | 11 + src/stores/project/tasks.ts | 16 +- src/views/TaskDetail.vue | 325 ++++++++++++- src/views/Tasks.vue | 441 +++++++++++++----- 13 files changed, 1097 insertions(+), 307 deletions(-) create mode 100644 docs/04-功能迭代/父子任务支持设计-2026-08-04.md diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index 9fdde4c..1158f91 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -89,6 +89,106 @@ pub async fn advance_task_atomic( }) } +// ============================================================ +// 父任务聚合 — 父任务 status 重算 + 推进联动(知识图谱 Phase 1 V29,设计 §2.1) +// ============================================================ + +/// 父任务 status 重算(容器模型,不走状态机)。 +/// +/// 聚合规则(设计 §2.1 父聚合规则,优先级从高到低): +/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点) +/// 2. 任一子 in_progress → 父 in_progress(执行中) +/// 3. 全子 done/cancelled → 父 done(全部完成/取消) +/// 4. 全子 todo → 父 todo(尚未开始) +/// 5. 其他混合态(如 todo+done)→ 父 in_progress(进行中,有进展未全完) +/// +/// 无子任务(悬空)→ 不重算,返回当前 status。 +/// 数据源 `repo.count_children_by_status`(一次 GROUP BY 查询,数据量小无压力); +/// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径)。 +/// 状态相同则不写(避免无谓 updated_at 抖动)。 +/// +/// 返回:重算后的父任务最新 status。 +pub async fn recompute_parent_status( + repo: &TaskRepo, + parent_id: &str, +) -> df_types::error::Result { + let counts = repo + .count_children_by_status(parent_id) + .await?; + // 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status + if counts.is_empty() { + return repo + .get_by_id(parent_id) + .await? + .map(|t| t.status.as_str().to_string()) + .ok_or_else(|| df_types::error::Error::NotFound(format!("父任务 {parent_id} 不存在"))); + } + + // 转 HashMap 便于按规则判定 + let map: std::collections::HashMap = counts.into_iter().collect(); + let total: i64 = map.values().sum(); + let blocked = map.get("blocked").copied().unwrap_or(0); + let in_progress = map.get("in_progress").copied().unwrap_or(0); + let todo = map.get("todo").copied().unwrap_or(0); + let done = map.get("done").copied().unwrap_or(0); + let cancelled = map.get("cancelled").copied().unwrap_or(0); + + // 聚合规则判定(优先级从高到低,首个命中即定) + let new_status = if blocked > 0 { + "blocked".to_string() + } else if in_progress > 0 { + "in_progress".to_string() + } else if (done + cancelled) == total { + // 全 done/cancelled → done(终端态聚合为 done) + "done".to_string() + } else if todo == total { + // 全 todo → todo(尚未开始) + "todo".to_string() + } else { + // 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中) + "in_progress".to_string() + }; + + // 读当前父 status,相同则不写(避免无谓 updated_at 抖动) + let current = repo + .get_by_id(parent_id) + .await? + .ok_or_else(|| df_types::error::Error::NotFound(format!("父任务 {parent_id} 不存在")))?; + if current.status.as_str() == new_status { + return Ok(new_status); + } + repo.set_status_for_aggregation(parent_id, &new_status).await?; + Ok(new_status) +} + +/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。 +/// +/// 推进链唯一 status 写入路径的两段式(设计 D3 统一,IPC/AI 工具/MCP 同源): +/// 1. 推进自身:调 `advance_task_atomic`(状态机校验 + 原子 CAS + review_rounds 累加)。 +/// 2. 父聚合:推进成功后若 `updated.parent_id` 有值,触发 `recompute_parent_status` 重算 +/// 父 status(父任务=容器模型,聚合规则见 recompute_parent_status)。父聚合失败仅 +/// tracing::warn 不阻断——子任务推进成功是主结果,父 status 漂移可后续修正。 +/// +/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。 +pub async fn advance_task_with_parent( + repo: &TaskRepo, + id: &str, + target_status: &str, +) -> df_types::error::Result { + let updated = advance_task_atomic(repo, id, target_status).await?; + if let Some(pid) = &updated.parent_id { + if let Err(e) = recompute_parent_status(repo, pid).await { + tracing::warn!( + task_id = %id, + parent_id = %pid, + error = %e, + "[父聚合] 重算父任务 status 失败(不阻断子任务推进)" + ); + } + } + Ok(updated) +} + // ============================================================ // DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态) // ============================================================ diff --git a/docs/04-功能迭代/父子任务支持设计-2026-08-04.md b/docs/04-功能迭代/父子任务支持设计-2026-08-04.md new file mode 100644 index 0000000..3f4e375 --- /dev/null +++ b/docs/04-功能迭代/父子任务支持设计-2026-08-04.md @@ -0,0 +1,215 @@ +# 父子任务支持设计 + +> 日期:2026-08-04 +> 目标:完成父任务/子任务的完整支持(数据→后端→前端),**UI/UX 重点设计**。 +> 关联:知识图谱 Phase 1 V29(tasks.parent_id 列 + 父聚合规则已落地数据层)、Phase 2 命令层已大部就绪。 + +--- + +## 1. 现状盘点(探索结论) + +### 已就绪(复用,不重复造) + +| 层 | 已有能力 | 位置 | +|---|---|---| +| 数据层 | `tasks.parent_id TEXT REFERENCES tasks(id)`(V29) | migrations.rs:755-761 | +| 数据层 | `TaskRecord.parent_id` / `TaskQuery.parent_id` | models.rs:114 / task_repo.rs:87 | +| 数据层 | `get_children` / `count_children_by_status` / `set_status_for_aggregation` | task_repo.rs:500/532/574 | +| 命令层 | `create_task`/`update_task` 的 parent_id 1 级嵌套校验 | task.rs:257-278/388-408 | +| 命令层 | `advance_task` 子任务推进后触发 `recompute_parent_status` | task.rs:533-542 | +| 命令层 | `get_task_tree`(父 + 直接子) | task.rs:854-867 | +| 约束 | 1 级嵌套(无孙任务),由 IPC 校验不进 DB 约束 | models.rs:108-111 | + +### 缺口(本次要补) + +1. **前端完全空白**:`TaskRecord/CreateTaskInput/TaskQuery` 无 parent_id/queue 字段;`Tasks.vue` 扁平列表无层级;`TaskDetail.vue` 无父子信息;新建弹窗无父任务选择。 +2. **父聚合只在 IPC advance_task 触发**:AI 工具 `ai/tools/task.rs:209` 与 df-mcp `tools.rs:577` 的 `advance_task` 都只调 `advance_task_atomic`,不重算父 status(与 IPC 不一致)。 +3. **df-mcp create_task 不支持 parent_id**:schema 无入参,构造时硬编码 `parent_id: None`(tools.rs:484)。 +4. **删除父任务后子任务悬挂**:`delete_task` 仅软删单条,子任务 `parent_id` 仍指向已软删父任务。 + +--- + +## 2. 设计决策 + +| # | 决策 | 理由 | +|---|---|---| +| D1 | 保持 1 级嵌套(无孙任务) | 与现有注释/校验/数据模型一致,不引入递归复杂度 | +| D2 | 复用 V29 `parent_id` 列,**不新增迁移** | 数据层已完备,无需 DB 变更 | +| D3 | 父聚合逻辑下沉 df-nodes 共享层,三方(IPC/AI/MCP)统一调用 | 消除双轨不一致,单一真相源 | +| D4 | 删除父任务 = 级联软删子任务(带确认提示) | 容器语义,删父即删整个工作单元;前端树数据可准确提示子任务数 | +| D5 | 任务列表页改**一次性加载 + 前端组装树**(limit 放大到 500 钳制上限),移除真分页 | 个人工具数据量小;树形需要完整父子关系,分页会割裂父/子 | +| D6 | 前端父任务进度条/徽章数据从树数据**前端计算**,不加新后端 API | 全量已在前端,无需额外往返 | + +--- + +## 3. 后端改动 + +### 3.1 df-nodes 共享父聚合(核心) + +`crates/df-nodes/src/task_advance_node.rs` 新增两个公共函数(迁移自 task.rs 私有实现): + +```rust +/// 父任务 status 重算(容器模型,不走状态机)。 +/// 聚合规则(优先级从高到低):任一 blocked→blocked;任一 in_progress→in_progress; +/// 全 done/cancelled→done;全 todo→todo;其他混合→in_progress。 +/// 无子任务(悬空)→ 不重算,返回当前 status。 +pub async fn recompute_parent_status( + repo: &TaskRepo, + parent_id: &str, +) -> df_types::error::Result + +/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。 +pub async fn advance_task_with_parent( + repo: &TaskRepo, + id: &str, + target_status: &str, +) -> df_types::error::Result +``` + +- `recompute_parent_status` 错误用 `Error::NotFound` / `Error::Storage` 包装。 +- 数据源 `repo.count_children_by_status`(一次 GROUP BY);写入 `repo.set_status_for_aggregation`。 +- 状态相同则不写(避免 updated_at 抖动)—— 逻辑原样迁移。 + +### 3.2 IPC `src-tauri/src/commands/task.rs` + +- `advance_task`:改为调 `df_nodes::task_advance_node::advance_task_with_parent`,删除本地 `recompute_parent_status` 私有函数。 +- `delete_task`:级联软删。新返回结构: + +```rust +#[derive(Debug, Serialize)] +pub struct TaskDeleteResult { + pub ok: bool, + /// 级联软删的子任务数 + pub cascaded: i32, +} +``` + +流程:`get_children(id)` → 逐个 `soft_delete(child)` → `soft_delete(id)` → emit `task_deleted`(父任务的事件)→ 返回 `{ok, cascaded}`。 + +### 3.3 AI 工具 `src-tauri/src/commands/ai/tools/task.rs` + +- `advance_task` handler:改调 `advance_task_with_parent`(与 IPC 同源,消除双轨)。 + +### 3.4 df-mcp `crates/df-mcp/src/tools.rs` + +- `create_task`:schema 增加 `parent_id`(可选 string);构造时透传;校验:parent 存在 + parent 自身无 parent_id(1 级嵌套),违反返回明确错误。 +- `advance_task`:改调 `advance_task_with_parent`。 + +### 3.5 契约(前后端共用) + +- `TaskRecord` 增 `queue: string`、`parent_id?: string | null`、`content_json?: string`。 +- `delete_task` 返回 `TaskDeleteResult { ok: boolean; cascaded: number }`(破坏性变更,仅 store/视图两处调用点,内部可控)。 + +--- + +## 4. 前端改动 + +### 4.1 类型与 API + +`src/api/types.ts`: +- `TaskRecord` + `queue: string`、`parent_id?: string | null`、`content_json?: string` +- `CreateTaskInput` + `queue?: string`、`parent_id?: string | null`(空串→后端视为 None) +- `TaskQuery` + `queue?: string | null`、`parent_id?: string | null` +- 新增 `TaskTreeNode { parent: TaskRecord; children: TaskRecord[] }` +- 新增 `TaskDeleteResult { ok: boolean; cascaded: number }` + +`src/api/task.ts`: +- `delete(id): Promise`(适配新返回) +- 新增 `getTree(id): Promise` → `invoke('get_task_tree', { parentId: id })` +- `create` 透传 `input`(已含 parent_id/queue) + +`src/stores/project/tasks.ts`: +- `deleteTask`:`state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id)`(父删连带子移除) +- `createTask` 入参类型 + `parent_id?: string | null` + +### 4.2 Tasks.vue — 树形列表(UI/UX 重点) + +**数据加载**:`buildTaskQuery()` 中 `limit` 固定放大(如 500,钳制上限),offset 恒 0;`totalTasks` 改用 `store.tasks.length`(一次加载即全部);**移除 ``**。 + +**树组装**(computed `taskRows`): +```ts +interface TaskRow { + task: TaskRecord + children: TaskRecord[] // 父任务的直接子(仅父有) + progress?: { done: number; total: number } // 父任务子进度 + isParent: boolean +} +``` +- 顶层 = `store.tasks.filter(t => !t.parent_id)`,按现有排序/项目分组逻辑处理。 +- 每个顶层任务的 children = `store.tasks.filter(t => t.parent_id === t.id)`(1 级嵌套,无需递归)。 +- 父任务 progress = children 中 `status === 'done' || 'cancelled'` 计数 / total。 + +**分组渲染改造**(每个项目组内): +``` +├ 顶层任务A(isParent=true) → 折叠箭头 + 标题 + 优先级 + 子进度徽章(2/5) + 迷你进度条 + 状态 + ⚙️ +│ └ 子任务A1/A2... → 缩进 + 左侧竖线引导线 + 圆点连接符,常规行操作 +├ 顶层任务B(isParent=false)→ 普通行 +``` + +**父任务行新增**: +- 折叠箭头 `▸/▾` 按钮(点击仅切换展开,`@click.stop` 防跳详情) +- 标题前父任务图标(如 `📑`,与子任务区分) +- **子进度徽章** `n/m`(如 `2/5`)+ **迷你进度条**(`.mini-progress` 渐变填充,done 百分比) +- 快捷菜单新增「+ 添加子任务」(`@click.stop`,带 parent_id 预填打开新建弹窗) +- 展开/折叠状态:`expandedParents: reactive(Set)` + localStorage 记忆(沿用折叠模式) + +**子任务行**: +- `padding-left` 缩进 + 左侧 `border-left` 引导线(延续父任务竖线)+ 行首圆点 `•`/连接符 +- 常规快捷操作(状态/优先级/删除)与顶层一致 +- 点击行跳 `/tasks/{child.id}` + +**新建任务弹窗**新增「父任务」下拉: +- 选项 = 当前选中项目的**顶层任务**列表 + 首项「无(顶层任务)」 +- 选择父任务时 `project_id` 锁定为该父任务所属项目(下拉只列该项目顶层任务) +- 提交时 `parent_id` 透传 + +**顶部「新建任务」**默认父任务=无(创建顶层任务)。 + +### 4.3 TaskDetail.vue — 父子面板 + +**父面包屑**:左栏「关联信息」面板顶部新增: +- 若 `task.parent_id` 有值:`父任务: → [标题]`(router-link 跳 `/tasks/{parent_id}`,parent 标题由 `getTaskTree` 或从列表解析) +- 数据源:load 时若 `task.parent_id` 有值,额外 `taskApi.get(parent_id)` 取标题。 + +**子任务面板**:若当前任务是父任务(`children.length > 0`),左栏新增「子任务」面板: +``` +┌ 子任务 (5) ─────────────┐ +│ ▓▓▓▓░░░░░ 3/5 完成 │ ← 顶部进度条 + 计数 +│ ├ [子任务1] [✅] │ ← 点击跳详情 +│ ├ [子任务2] [🔨] ⚙️ │ ← 行快捷推进 +│ └ [+ 添加子任务] │ +└──────────────────────────┘ +``` +- 数据:load 时 `taskApi.list({ project_id, parent_id: task.id })`(或 `getTree`) +- 子任务行:标题 + 状态徽章 + 优先级徽章;点击跳转;⚙️ 快捷菜单(复用列表页 quickStatuses/quickPriorities 模式,advance 后刷新子列表) +- 「+ 添加子任务」按钮:打开小弹窗(标题 + 优先级 + 描述),project_id/parent_id 继承当前任务 + +**子任务空态**:父任务无子任务时显示「暂无子任务」+ 添加入口(父任务详情可空树创建)。 + +### 4.4 i18n 新增 key + +`zh-CN/tasks.ts` + `en/tasks.ts`: +```ts +modal: { ..., parentTask: '父任务', parentPlaceholder: '无(顶层任务)' } +addSubtask: '+ 添加子任务' +tree: { progress: '进度' } +confirmDeleteWithChildren: '确定删除「{title}」吗?将同时删除 {n} 个子任务。' +``` + +`zh-CN/taskDetail.ts` + `en/taskDetail.ts`: +```ts +parentTask: '父任务' +childrenTitle: '子任务' +subtaskCount: '{n} 个子任务' +childEmpty: '暂无子任务' +addSubtask: '+ 添加子任务' +progressTitle: '完成进度' +``` + +--- + +## 5. 边界与不做 + +- **不做**:孙任务(D1)、任务回收站前端 UI(list_deleted_tasks 无命令,超范围,登记待办)、queue 管理池看板视图(move_task_queue 前端 UI,超范围)。 +- **回归风险**:delete_task 返回结构变更影响 `store.deleteTask`/Tasks.vue 两处;Tasks.vue 移除分页器影响 `Paginator`/`totalTasks` 逻辑——核查时重点验证。 +- **UI 设计原则**:树形沿用现有任务卡视觉(CSS token、状态徽章、快捷菜单),父/子层级用「缩进 + 竖线 + 折叠箭头 + 进度条」表达,不引入新 UI 库。 diff --git a/src-tauri/src/commands/ai/tools/task.rs b/src-tauri/src/commands/ai/tools/task.rs index c178d10..770e880 100644 --- a/src-tauri/src/commands/ai/tools/task.rs +++ b/src-tauri/src/commands/ai/tools/task.rs @@ -203,10 +203,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let target_status = args["target_status"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?; - // 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds), - // 与 commands::task::advance_task IPC 同源,避免双轨。 + // 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds + 父聚合), + // 与 commands::task::advance_task IPC 同源,避免双轨。设计 D3:子任务推进后 + // 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。 let repo = df_storage::crud::TaskRepo::new(&db); - let updated = df_nodes::task_advance_node::advance_task_atomic( + let updated = df_nodes::task_advance_node::advance_task_with_parent( &repo, id, target_status, ).await?; // 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。 diff --git a/src-tauri/src/commands/ai/tools/task_graph.rs b/src-tauri/src/commands/ai/tools/task_graph.rs index 67146a8..e0cfdbb 100644 --- a/src-tauri/src/commands/ai/tools/task_graph.rs +++ b/src-tauri/src/commands/ai/tools/task_graph.rs @@ -138,11 +138,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { // ── move_task_queue (Medium,设计 §2.1 + §五) ── // 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。 - // 对齐 commands::task::move_task_queue 语义: + // 全部逻辑收口到 TaskRepo::move_task_queue 单事务方法(读当前 → 联动 status → + // 写 queue+status 于同一 transaction),与 commands::task::move_task_queue 共用,防漂移。 // - done → status 强制 done / backlog → status 强制 todo // - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo // - decision → status 不变(待决策池保留执行态) - // status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。 declare_tool!( registry, db: Arc, @@ -156,50 +156,14 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { .ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))? .trim() .to_string(); - // queue 白名单校验(对标 commands::task::validate_queue) - const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; - if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) { - anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES); - } + // 单事务原子移动:读当前 → 一致性联动 status → 写 queue+status 全在 + // TaskRepo::move_task_queue 同一 transaction 内完成(queue 白名单/联动规则单点)。 let repo = df_storage::crud::TaskRepo::new(&db); - let current = repo - .get_by_id(id) + let updated = repo + .move_task_queue(id, &new_queue) .await? .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?; - - // 一致性约束联动:根据 new_queue 决定 status 是否需调整 - // (对标 commands::task::move_task_queue 同源逻辑,单一真相源) - const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"]; - let new_status = match new_queue.as_str() { - "done" => "done".to_string(), - "backlog" => "todo".to_string(), - "active" => { - if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { - current.status.as_str().to_string() - } else { - "in_progress".to_string() - } - } - "todo" => "todo".to_string(), - "decision" => current.status.as_str().to_string(), - _ => unreachable!("queue 白名单已收口"), - }; - - // 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动) - if current.queue != new_queue { - repo.update_field(id, "queue", &new_queue).await?; - } - // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) - if current.status.as_str() != new_status { - repo.set_status_for_aggregation(id, &new_status).await?; - } - - // 回读最新记录返回 - let updated = repo - .get_by_id(id) - .await? - .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?; Ok(serde_json::to_value(&updated)?) } ); diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 573dec6..11ba241 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -440,13 +440,39 @@ pub async fn update_task( Ok(true) } +/// 删除任务结果(级联删子任务后返回) +#[derive(Debug, Serialize)] +pub struct TaskDeleteResult { + /// 是否删除成功 + pub ok: bool, + /// 级联软删的子任务数 + pub cascaded: i32, +} + /// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。 /// +/// **级联软删子任务(设计 D4,容器语义)**:删除父任务即删整个工作单元。先 `get_children(id)` +/// 取未删子任务列表,逐个 `soft_delete` 计数(cascaded),再 `soft_delete(id)` 父任务。 +/// 1 级嵌套(设计 D2),仅一层子任务,无需递归。 +/// /// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。 /// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。 #[tauri::command] -pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result { +pub async fn delete_task( + state: State<'_, AppState>, + id: String, +) -> Result { let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?; + // 级联软删子任务(容器语义 D4):逐个 soft_delete 并计数 + let children = state.tasks.get_children(&id).await.map_err(err_str)?; + let mut cascaded = 0; + for child in &children { + let child_ok = state.tasks.soft_delete(&child.id).await.map_err(err_str)?; + if child_ok { + cascaded += 1; + } + } + // 软删父任务本身 let ok = state.tasks.soft_delete(&id).await.map_err(err_str)?; if ok { if let Some(r) = rec { @@ -462,7 +488,7 @@ pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result, id: String) -> Result, parent_id: &str) -> Result { - let counts = state - .tasks - .count_children_by_status(parent_id) - .await - .map_err(err_str)?; - // 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status - if counts.is_empty() { - return state - .tasks - .get_by_id(parent_id) - .await - .map_err(err_str)? - .map(|t| t.status.as_str().to_string()) - .ok_or_else(|| format!("父任务 {parent_id} 不存在")); - } - - // 转 HashMap 便于按规则判定 - let map: std::collections::HashMap = counts.into_iter().collect(); - let total: i64 = map.values().sum(); - let blocked = map.get("blocked").copied().unwrap_or(0); - let in_progress = map.get("in_progress").copied().unwrap_or(0); - let todo = map.get("todo").copied().unwrap_or(0); - let done = map.get("done").copied().unwrap_or(0); - let cancelled = map.get("cancelled").copied().unwrap_or(0); - - // 聚合规则判定(优先级从高到低,首个命中即定) - let new_status = if blocked > 0 { - "blocked".to_string() - } else if in_progress > 0 { - "in_progress".to_string() - } else if (done + cancelled) == total { - // 全 done/cancelled → done(终端态聚合为 done) - "done".to_string() - } else if todo == total { - // 全 todo → todo(尚未开始) - "todo".to_string() - } else { - // 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中) - "in_progress".to_string() - }; - - // 读当前父 status,相同则不写(避免无谓 updated_at 抖动) - let current = state - .tasks - .get_by_id(parent_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("父任务 {parent_id} 不存在"))?; - if current.status.as_str() == new_status { - return Ok(new_status); - } - state - .tasks - .set_status_for_aggregation(parent_id, &new_status) - .await - .map_err(err_str)?; - Ok(new_status) -} - // ============================================================ // 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表) // ============================================================ @@ -745,8 +680,11 @@ pub async fn list_task_links( /// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status /// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。 /// -/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口, -/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。 +/// 一致性联动 + 写 queue/status 收口到 `TaskRepo::move_task_queue` **单事务原子方法** +/// (G1.4:读当前 → 联动 status → 写 queue+status 于同一 transaction,防两段式独立写的 +/// 非原子中间态),与 AI 工具共用防漂移。status 联动绕过状态机收口(move_task_queue 是 +/// 合法的非状态机 status 联动路径,非 advance_task 状态机路径),soft 删回收站任务不可 +/// move(repo 方法返回 None)。 #[tauri::command] pub async fn move_task_queue( state: State<'_, AppState>, @@ -756,7 +694,7 @@ pub async fn move_task_queue( let new_queue = new_queue.trim().to_string(); validate_queue(&new_queue)?; - // 读当前任务(取当前 status 做一致性联动决策) + // 读当前(取旧 queue 做事件埋点决策;一致性联动 + 原子写全在 repo 单事务内完成)。 let current = state .tasks .get_by_id(&id) @@ -764,39 +702,15 @@ pub async fn move_task_queue( .map_err(err_str)? .ok_or_else(|| format!("任务 {id} 不存在"))?; - // 一致性约束联动:根据 new_queue 决定 status 是否需调整 - let new_status = match new_queue.as_str() { - "done" => "done".to_string(), - "backlog" => "todo".to_string(), - "active" => { - if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { - current.status.as_str().to_string() // 已在执行中三态,保留 - } else { - "in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态) - } - } - "todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态) - "decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态) - _ => unreachable!("validate_queue 已收口"), - }; - - // 写 queue:走通用 update_field(queue 已在 tasks 白名单登记,知识图谱 Phase 1 V29 新增)。 - // 与 current.queue 不同才写(避免无谓 updated_at 抖动)。 - if current.queue != new_queue { - state - .tasks - .update_field(&id, "queue", &new_queue) - .await - .map_err(err_str)?; - } - // 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径) - if current.status.as_str() != new_status { - state - .tasks - .set_status_for_aggregation(&id, &new_status) - .await - .map_err(err_str)?; - } + // 单事务原子移动(读当前 → 联动 status → 写 queue+status,见 TaskRepo::move_task_queue)。 + // G1.4:此前两段式(update_field queue + set_status_for_aggregation)两次独立写非原子, + // 可被并发读/写破坏;现收口为单一 repo 方法,commands 与 AI 工具共用防漂移。 + let updated = state + .tasks + .move_task_queue(&id, &new_queue) + .await + .map_err(err_str)? + .ok_or_else(|| format!("任务 {id} 不存在"))?; // 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。 // 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。 @@ -821,13 +735,7 @@ pub async fn move_task_queue( .await; } - // 回读最新记录返回 - state - .tasks - .get_by_id(&id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)")) + Ok(updated) } // ============================================================ diff --git a/src/api/task.ts b/src/api/task.ts index 8e66990..c9b79cb 100644 --- a/src/api/task.ts +++ b/src/api/task.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { TaskRecord, CreateTaskInput, TaskQuery } from './types' +import type { TaskRecord, CreateTaskInput, TaskQuery, TaskTreeNode, TaskDeleteResult } from './types' export const taskApi = { /** @@ -35,10 +35,22 @@ export const taskApi = { return invoke('update_task', { id, field, value }) }, - delete(id: string): Promise { + /** + * 删除任务。后端 delete_task 现为级联软删:删除父任务时一并软删其子任务 + * (返回 TaskDeleteResult.ok + cascaded 子任务数);删除子任务仅删自身。 + */ + delete(id: string): Promise { return invoke('delete_task', { id }) }, + /** + * 获取任务树(父子任务,1 级嵌套,无孙任务)。 + * 后端 get_task_tree 按 parentId 返回 TaskTreeNode(parent + 直接 children)。 + */ + getTree(id: string): Promise { + return invoke('get_task_tree', { parentId: id }) + }, + /** * F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。 * 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时), diff --git a/src/i18n/en/taskDetail.ts b/src/i18n/en/taskDetail.ts index 1070a76..171af8e 100644 --- a/src/i18n/en/taskDetail.ts +++ b/src/i18n/en/taskDetail.ts @@ -67,5 +67,13 @@ export default { collapse: 'Collapse', // Related info panel title (wide-screen right column / info grouping) relatedTitle: 'Related', + // F-260805 parent/child tasks: parent breadcrumb + subtask panel + parentTask: 'Parent Task', + childrenTitle: 'Subtasks', + subtaskCount: '{n} subtasks', + childEmpty: 'No subtasks yet', + addSubtask: '+ Add Subtask', + progressTitle: 'Progress', + addSubtaskFailed: 'Failed to create subtask: {msg}', }, } diff --git a/src/i18n/en/tasks.ts b/src/i18n/en/tasks.ts index 28dc902..1c25eb3 100644 --- a/src/i18n/en/tasks.ts +++ b/src/i18n/en/tasks.ts @@ -39,6 +39,14 @@ export default { empty: 'No tasks yet', }, confirmDelete: 'Delete task "{title}"? This action cannot be undone.', + // F-260805 parent/child tasks: deleting a parent cascades soft-delete of children + confirmDeleteWithChildren: 'Delete "{title}"? {n} subtasks will also be deleted.', + // F-260805 parent/child tasks: tree child progress badge / mini progress bar tooltip + tree: { + progress: 'Progress', + }, + // F-260805 parent/child tasks: add-subtask entry in parent quick menu + addSubtask: '+ Add Subtask', // Quick action menu (task card quick status/priority/delete) quickActions: 'Quick Actions', quickStatus: 'Status', @@ -64,6 +72,9 @@ export default { priorityHigh: 'P1 High', priorityMedium: 'P2 Medium', priorityLow: 'P3 Low', + // F-260805 parent/child tasks: parent dropdown in create modal + parentTask: 'Parent Task', + parentPlaceholder: 'None (top-level task)', }, // Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) — D-260616-01 aligned to backend 7 states status: { diff --git a/src/i18n/zh-CN/taskDetail.ts b/src/i18n/zh-CN/taskDetail.ts index 23fe764..f229df0 100644 --- a/src/i18n/zh-CN/taskDetail.ts +++ b/src/i18n/zh-CN/taskDetail.ts @@ -67,5 +67,13 @@ export default { collapse: '收起', // 关联信息面板标题(宽屏右栏 / 信息分组) relatedTitle: '关联信息', + // F-260805 父子任务:父面包屑 + 子任务面板 + parentTask: '父任务', + childrenTitle: '子任务', + subtaskCount: '{n} 个子任务', + childEmpty: '暂无子任务', + addSubtask: '+ 添加子任务', + progressTitle: '完成进度', + addSubtaskFailed: '创建子任务失败: {msg}', }, } diff --git a/src/i18n/zh-CN/tasks.ts b/src/i18n/zh-CN/tasks.ts index c180102..ede660c 100644 --- a/src/i18n/zh-CN/tasks.ts +++ b/src/i18n/zh-CN/tasks.ts @@ -39,6 +39,14 @@ export default { empty: '暂无任务', }, confirmDelete: '确定删除任务「{title}」吗?此操作不可撤销。', + // F-260805 父子任务:删除父任务时级联软删子任务,确认文案含子任务数 + confirmDeleteWithChildren: '确定删除「{title}」吗?将同时删除 {n} 个子任务。', + // F-260805 父子任务:树形列表子进度徽章/迷你进度条 tooltip + tree: { + progress: '进度', + }, + // F-260805 父子任务:父任务快捷菜单「添加子任务」 + addSubtask: '+ 添加子任务', // 快捷操作菜单(任务卡片快捷改状态/优先级/删除) quickActions: '快捷操作', quickStatus: '状态', @@ -64,6 +72,9 @@ export default { priorityHigh: 'P1 高', priorityMedium: 'P2 中', priorityLow: 'P3 低', + // F-260805 父子任务:新建弹窗「父任务」下拉 + parentTask: '父任务', + parentPlaceholder: '无(顶层任务)', }, // 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态 status: { diff --git a/src/stores/project/tasks.ts b/src/stores/project/tasks.ts index a50aa36..318ca17 100644 --- a/src/stores/project/tasks.ts +++ b/src/stores/project/tasks.ts @@ -22,7 +22,17 @@ export function createTasksStore() { }) } - async function createTask(input: { project_id: ProjectId; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string; idea_id?: string }) { + async function createTask(input: { + project_id: ProjectId + title: string + description?: string + priority?: number + branch_name?: string + assignee?: string + idea_id?: string + /** 父任务 ID(1 级嵌套,可空);传空串后端视为 None */ + parent_id?: string | null + }) { const record = await runWithCatch(state, t('tasks.err.createFailed'), async () => { const r = await taskApi.create(input) state.tasks.push(r) @@ -43,8 +53,10 @@ export function createTasksStore() { async function deleteTask(id: string) { await runWithCatch(state, t('tasks.err.deleteFailed'), async () => { + // 后端已级联软删子任务(delete_task 返回 { ok, cascaded }); + // 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id) await taskApi.delete(id) - state.tasks = state.tasks.filter(t => t.id !== id) + state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id) }) } diff --git a/src/views/TaskDetail.vue b/src/views/TaskDetail.vue index 1026cdd..e0bffdd 100644 --- a/src/views/TaskDetail.vue +++ b/src/views/TaskDetail.vue @@ -71,6 +71,13 @@

{{ $t('taskDetail.relatedTitle') }}

+ +
+ {{ $t('taskDetail.parentTask') }} + + {{ parentTaskTitle || task.parent_id }} + +
{{ $t('taskDetail.project') }} @@ -116,6 +123,54 @@
+ + +
+
+

{{ $t('taskDetail.childrenTitle') }}

+ {{ $t('taskDetail.subtaskCount', { n: children.length }) }} +
+ +
+
+
+
+ {{ $t('taskDetail.progressTitle') }}: {{ subtaskProgress.done }}/{{ subtaskProgress.total }} +
+
+
+ {{ child.title }} + {{ $t(taskStatusLabel(child.status)) }} + {{ priorityLabel(child.priority) }} + + +
+
+
{{ $t('tasks.quickStatus') }}
+ +
+
+
+
{{ $t('tasks.quickPriority') }}
+ +
+
+
+
+ +
{{ $t('taskDetail.childEmpty') }}
+ +
@@ -144,13 +199,41 @@ + + +