新增: 父子任务支持(数据→后端→前端全链路,会话前基线收尾)
- df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具 - 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板 - 设计文档: 父子任务支持设计-2026-08-04
This commit is contained in:
@@ -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<String> {
|
||||||
|
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<status, count> 便于按规则判定
|
||||||
|
let map: std::collections::HashMap<String, i64> = 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<TaskRecord> {
|
||||||
|
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 内的形态)
|
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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<String>
|
||||||
|
|
||||||
|
/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。
|
||||||
|
pub async fn advance_task_with_parent(
|
||||||
|
repo: &TaskRepo,
|
||||||
|
id: &str,
|
||||||
|
target_status: &str,
|
||||||
|
) -> df_types::error::Result<TaskRecord>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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<TaskDeleteResult>`(适配新返回)
|
||||||
|
- 新增 `getTree(id): Promise<TaskTreeNode>` → `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`(一次加载即全部);**移除 `<Paginator>`**。
|
||||||
|
|
||||||
|
**树组装**(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<string>)` + 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 库。
|
||||||
@@ -203,10 +203,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||||
let target_status = args["target_status"].as_str()
|
let target_status = args["target_status"].as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
||||||
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
|
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds + 父聚合),
|
||||||
// 与 commands::task::advance_task IPC 同源,避免双轨。
|
// 与 commands::task::advance_task IPC 同源,避免双轨。设计 D3:子任务推进后
|
||||||
|
// 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。
|
||||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
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,
|
&repo, id, target_status,
|
||||||
).await?;
|
).await?;
|
||||||
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
|
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
|
||||||
|
|||||||
@@ -138,11 +138,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
|
|
||||||
// ── move_task_queue (Medium,设计 §2.1 + §五) ──
|
// ── move_task_queue (Medium,设计 §2.1 + §五) ──
|
||||||
// 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。
|
// 跨池移动任务(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
|
// - done → status 强制 done / backlog → status 强制 todo
|
||||||
// - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo
|
// - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo
|
||||||
// - decision → status 不变(待决策池保留执行态)
|
// - decision → status 不变(待决策池保留执行态)
|
||||||
// status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。
|
|
||||||
declare_tool!(
|
declare_tool!(
|
||||||
registry,
|
registry,
|
||||||
db: Arc<Database>,
|
db: Arc<Database>,
|
||||||
@@ -156,50 +156,14 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))?
|
.ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.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 repo = df_storage::crud::TaskRepo::new(&db);
|
||||||
let current = repo
|
let updated = repo
|
||||||
.get_by_id(id)
|
.move_task_queue(id, &new_queue)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?;
|
.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)?)
|
Ok(serde_json::to_value(&updated)?)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+51
-143
@@ -440,13 +440,39 @@ pub async fn update_task(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除任务结果(级联删子任务后返回)
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TaskDeleteResult {
|
||||||
|
/// 是否删除成功
|
||||||
|
pub ok: bool,
|
||||||
|
/// 级联软删的子任务数
|
||||||
|
pub cascaded: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
||||||
///
|
///
|
||||||
|
/// **级联软删子任务(设计 D4,容器语义)**:删除父任务即删整个工作单元。先 `get_children(id)`
|
||||||
|
/// 取未删子任务列表,逐个 `soft_delete` 计数(cascaded),再 `soft_delete(id)` 父任务。
|
||||||
|
/// 1 级嵌套(设计 D2),仅一层子任务,无需递归。
|
||||||
|
///
|
||||||
/// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。
|
/// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。
|
||||||
/// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。
|
/// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
pub async fn delete_task(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<TaskDeleteResult, String> {
|
||||||
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
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)?;
|
let ok = state.tasks.soft_delete(&id).await.map_err(err_str)?;
|
||||||
if ok {
|
if ok {
|
||||||
if let Some(r) = rec {
|
if let Some(r) = rec {
|
||||||
@@ -462,7 +488,7 @@ pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ok)
|
Ok(TaskDeleteResult { ok, cascaded })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
||||||
@@ -491,16 +517,16 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
|
|||||||
|
|
||||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||||
///
|
///
|
||||||
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
|
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
|
||||||
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
|
/// 落 df-nodes::task_advance_node,本命令只做参数转发与错误串化。
|
||||||
///
|
///
|
||||||
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
|
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
|
||||||
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
|
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
|
||||||
///
|
///
|
||||||
/// **知识图谱 Phase 1 V29 父聚合(对标设计 §2.1)**:推进完成后,若推进的任务有 parent_id,
|
/// **知识图谱 Phase 1 V29 父聚合(设计 §2.1)**:推进走 `advance_task_with_parent`,推进完成后
|
||||||
/// 触发父任务 status 重算(recompute_parent_status)。父任务=容器模型,status 不走状态机,
|
/// 若任务有 parent_id,自动触发父任务 status 重算(recompute_parent_status,df-nodes 共享层
|
||||||
/// 由子任务聚合计算(聚合规则见 recompute_parent_status)。聚合失败不阻断推进(best-effort,
|
/// D3)。父任务=容器模型,status 不走状态机,由子任务聚合计算。聚合失败不阻断推进(best-effort,
|
||||||
/// 对标设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
|
/// 设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
|
||||||
///
|
///
|
||||||
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -518,7 +544,9 @@ pub async fn advance_task(
|
|||||||
.map_err(err_str)?
|
.map_err(err_str)?
|
||||||
.map(|t| t.status);
|
.map(|t| t.status);
|
||||||
|
|
||||||
let updated = df_nodes::task_advance_node::advance_task_atomic(
|
// 推进 + 父聚合(df-nodes 共享层 D3,与 AI 工具/MCP 同源,消除双轨):
|
||||||
|
// 子任务推进成功后自动触发父 status 聚合(容器模型,聚合失败仅 warn 不阻断)。
|
||||||
|
let updated = df_nodes::task_advance_node::advance_task_with_parent(
|
||||||
&state.tasks,
|
&state.tasks,
|
||||||
&id,
|
&id,
|
||||||
&target_status,
|
&target_status,
|
||||||
@@ -526,21 +554,6 @@ pub async fn advance_task(
|
|||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
|
|
||||||
// 父聚合(知识图谱 Phase 1 V29):推进的子任务有 parent_id → 重算父 status。
|
|
||||||
// best-effort:聚合失败不阻断推进(子任务已成功推进是主结果,父 status 漂移可后续修正),
|
|
||||||
// 仅 warn 日志记录。父任务 status 不走状态机,经专用方法 set_status_for_aggregation
|
|
||||||
// 直接写(绕过 D-260616-04 status 收口:父任务=容器模型,聚合规则是唯一非状态机写入路径)。
|
|
||||||
if let Some(pid) = &updated.parent_id {
|
|
||||||
if let Err(e) = recompute_parent_status(&state, pid).await {
|
|
||||||
tracing::warn!(
|
|
||||||
task_id = %id,
|
|
||||||
parent_id = %pid,
|
|
||||||
error = %e,
|
|
||||||
"[父聚合] 重算父任务 status 失败(不阻断子任务推进)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
|
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
|
||||||
emit_event(
|
emit_event(
|
||||||
&state,
|
&state,
|
||||||
@@ -556,84 +569,6 @@ pub async fn advance_task(
|
|||||||
Ok(updated)
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 知识图谱 Phase 1:父任务聚合(对标设计 §2.1 父聚合规则)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// 父任务 status 重算(对标设计 §2.1 聚合规则,D3 父任务=容器模型)。
|
|
||||||
///
|
|
||||||
/// 聚合规则(设计 §2.1「父任务推导 status」表,优先级从高到低):
|
|
||||||
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
|
|
||||||
/// 2. 任一子 in_progress → 父 in_progress(执行中)
|
|
||||||
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
|
|
||||||
/// 4. 全子 todo → 父 todo(尚未开始)
|
|
||||||
/// 5. 其他混合态(如 todo+done) → 父 in_progress(进行中,有进展未全完)
|
|
||||||
///
|
|
||||||
/// 触发时机:advance_task 子任务推进成功后,若子任务有 parent_id 则调本函数。
|
|
||||||
/// 数据源:count_children_by_status(一次 GROUP BY 查询,数据量小无压力)。
|
|
||||||
/// 写入:set_status_for_aggregation(父任务 status 唯一非状态机写入路径)。
|
|
||||||
///
|
|
||||||
/// 返回:重算后的父任务最新 status(若与当前相同则不写,返当前值)。
|
|
||||||
async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -> Result<String, String> {
|
|
||||||
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<status, count> 便于按规则判定
|
|
||||||
let map: std::collections::HashMap<String, i64> = 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 工具表)
|
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -745,8 +680,11 @@ pub async fn list_task_links(
|
|||||||
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
|
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
|
||||||
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
|
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
|
||||||
///
|
///
|
||||||
/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口,
|
/// 一致性联动 + 写 queue/status 收口到 `TaskRepo::move_task_queue` **单事务原子方法**
|
||||||
/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。
|
/// (G1.4:读当前 → 联动 status → 写 queue+status 于同一 transaction,防两段式独立写的
|
||||||
|
/// 非原子中间态),与 AI 工具共用防漂移。status 联动绕过状态机收口(move_task_queue 是
|
||||||
|
/// 合法的非状态机 status 联动路径,非 advance_task 状态机路径),soft 删回收站任务不可
|
||||||
|
/// move(repo 方法返回 None)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn move_task_queue(
|
pub async fn move_task_queue(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -756,7 +694,7 @@ pub async fn move_task_queue(
|
|||||||
let new_queue = new_queue.trim().to_string();
|
let new_queue = new_queue.trim().to_string();
|
||||||
validate_queue(&new_queue)?;
|
validate_queue(&new_queue)?;
|
||||||
|
|
||||||
// 读当前任务(取当前 status 做一致性联动决策)
|
// 读当前(取旧 queue 做事件埋点决策;一致性联动 + 原子写全在 repo 单事务内完成)。
|
||||||
let current = state
|
let current = state
|
||||||
.tasks
|
.tasks
|
||||||
.get_by_id(&id)
|
.get_by_id(&id)
|
||||||
@@ -764,39 +702,15 @@ pub async fn move_task_queue(
|
|||||||
.map_err(err_str)?
|
.map_err(err_str)?
|
||||||
.ok_or_else(|| format!("任务 {id} 不存在"))?;
|
.ok_or_else(|| format!("任务 {id} 不存在"))?;
|
||||||
|
|
||||||
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
|
// 单事务原子移动(读当前 → 联动 status → 写 queue+status,见 TaskRepo::move_task_queue)。
|
||||||
let new_status = match new_queue.as_str() {
|
// G1.4:此前两段式(update_field queue + set_status_for_aggregation)两次独立写非原子,
|
||||||
"done" => "done".to_string(),
|
// 可被并发读/写破坏;现收口为单一 repo 方法,commands 与 AI 工具共用防漂移。
|
||||||
"backlog" => "todo".to_string(),
|
let updated = state
|
||||||
"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
|
.tasks
|
||||||
.update_field(&id, "queue", &new_queue)
|
.move_task_queue(&id, &new_queue)
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?
|
||||||
}
|
.ok_or_else(|| format!("任务 {id} 不存在"))?;
|
||||||
// 写 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)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
||||||
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
|
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
|
||||||
@@ -821,13 +735,7 @@ pub async fn move_task_queue(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回读最新记录返回
|
Ok(updated)
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.get_by_id(&id)
|
|
||||||
.await
|
|
||||||
.map_err(err_str)?
|
|
||||||
.ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
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 = {
|
export const taskApi = {
|
||||||
/**
|
/**
|
||||||
@@ -35,10 +35,22 @@ export const taskApi = {
|
|||||||
return invoke('update_task', { id, field, value })
|
return invoke('update_task', { id, field, value })
|
||||||
},
|
},
|
||||||
|
|
||||||
delete(id: string): Promise<boolean> {
|
/**
|
||||||
|
* 删除任务。后端 delete_task 现为级联软删:删除父任务时一并软删其子任务
|
||||||
|
* (返回 TaskDeleteResult.ok + cascaded 子任务数);删除子任务仅删自身。
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<TaskDeleteResult> {
|
||||||
return invoke('delete_task', { id })
|
return invoke('delete_task', { id })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务树(父子任务,1 级嵌套,无孙任务)。
|
||||||
|
* 后端 get_task_tree 按 parentId 返回 TaskTreeNode(parent + 直接 children)。
|
||||||
|
*/
|
||||||
|
getTree(id: string): Promise<TaskTreeNode> {
|
||||||
|
return invoke('get_task_tree', { parentId: id })
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。
|
* F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。
|
||||||
* 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时),
|
* 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时),
|
||||||
|
|||||||
@@ -67,5 +67,13 @@ export default {
|
|||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
// Related info panel title (wide-screen right column / info grouping)
|
// Related info panel title (wide-screen right column / info grouping)
|
||||||
relatedTitle: 'Related',
|
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}',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ export default {
|
|||||||
empty: 'No tasks yet',
|
empty: 'No tasks yet',
|
||||||
},
|
},
|
||||||
confirmDelete: 'Delete task "{title}"? This action cannot be undone.',
|
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)
|
// Quick action menu (task card quick status/priority/delete)
|
||||||
quickActions: 'Quick Actions',
|
quickActions: 'Quick Actions',
|
||||||
quickStatus: 'Status',
|
quickStatus: 'Status',
|
||||||
@@ -64,6 +72,9 @@ export default {
|
|||||||
priorityHigh: 'P1 High',
|
priorityHigh: 'P1 High',
|
||||||
priorityMedium: 'P2 Medium',
|
priorityMedium: 'P2 Medium',
|
||||||
priorityLow: 'P3 Low',
|
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 labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) — D-260616-01 aligned to backend 7 states
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -67,5 +67,13 @@ export default {
|
|||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
// 关联信息面板标题(宽屏右栏 / 信息分组)
|
// 关联信息面板标题(宽屏右栏 / 信息分组)
|
||||||
relatedTitle: '关联信息',
|
relatedTitle: '关联信息',
|
||||||
|
// F-260805 父子任务:父面包屑 + 子任务面板
|
||||||
|
parentTask: '父任务',
|
||||||
|
childrenTitle: '子任务',
|
||||||
|
subtaskCount: '{n} 个子任务',
|
||||||
|
childEmpty: '暂无子任务',
|
||||||
|
addSubtask: '+ 添加子任务',
|
||||||
|
progressTitle: '完成进度',
|
||||||
|
addSubtaskFailed: '创建子任务失败: {msg}',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ export default {
|
|||||||
empty: '暂无任务',
|
empty: '暂无任务',
|
||||||
},
|
},
|
||||||
confirmDelete: '确定删除任务「{title}」吗?此操作不可撤销。',
|
confirmDelete: '确定删除任务「{title}」吗?此操作不可撤销。',
|
||||||
|
// F-260805 父子任务:删除父任务时级联软删子任务,确认文案含子任务数
|
||||||
|
confirmDeleteWithChildren: '确定删除「{title}」吗?将同时删除 {n} 个子任务。',
|
||||||
|
// F-260805 父子任务:树形列表子进度徽章/迷你进度条 tooltip
|
||||||
|
tree: {
|
||||||
|
progress: '进度',
|
||||||
|
},
|
||||||
|
// F-260805 父子任务:父任务快捷菜单「添加子任务」
|
||||||
|
addSubtask: '+ 添加子任务',
|
||||||
// 快捷操作菜单(任务卡片快捷改状态/优先级/删除)
|
// 快捷操作菜单(任务卡片快捷改状态/优先级/删除)
|
||||||
quickActions: '快捷操作',
|
quickActions: '快捷操作',
|
||||||
quickStatus: '状态',
|
quickStatus: '状态',
|
||||||
@@ -64,6 +72,9 @@ export default {
|
|||||||
priorityHigh: 'P1 高',
|
priorityHigh: 'P1 高',
|
||||||
priorityMedium: 'P2 中',
|
priorityMedium: 'P2 中',
|
||||||
priorityLow: 'P3 低',
|
priorityLow: 'P3 低',
|
||||||
|
// F-260805 父子任务:新建弹窗「父任务」下拉
|
||||||
|
parentTask: '父任务',
|
||||||
|
parentPlaceholder: '无(顶层任务)',
|
||||||
},
|
},
|
||||||
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态
|
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -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 record = await runWithCatch(state, t('tasks.err.createFailed'), async () => {
|
||||||
const r = await taskApi.create(input)
|
const r = await taskApi.create(input)
|
||||||
state.tasks.push(r)
|
state.tasks.push(r)
|
||||||
@@ -43,8 +53,10 @@ export function createTasksStore() {
|
|||||||
|
|
||||||
async function deleteTask(id: string) {
|
async function deleteTask(id: string) {
|
||||||
await runWithCatch(state, t('tasks.err.deleteFailed'), async () => {
|
await runWithCatch(state, t('tasks.err.deleteFailed'), async () => {
|
||||||
|
// 后端已级联软删子任务(delete_task 返回 { ok, cascaded });
|
||||||
|
// 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id)
|
||||||
await taskApi.delete(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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+323
-2
@@ -71,6 +71,13 @@
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
|
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
|
||||||
<div class="task-info">
|
<div class="task-info">
|
||||||
|
<!-- F-260805 父子任务:父面包屑,仅子任务(parent_id 有值)显示,跳 /tasks/{parent_id} -->
|
||||||
|
<div v-if="task.parent_id" class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.parentTask') }}</span>
|
||||||
|
<span class="value">
|
||||||
|
<router-link :to="`/tasks/${task.parent_id}`" class="project-link">{{ parentTaskTitle || task.parent_id }}</router-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">{{ $t('taskDetail.project') }}</span>
|
<span class="label">{{ $t('taskDetail.project') }}</span>
|
||||||
<span class="value">
|
<span class="value">
|
||||||
@@ -116,6 +123,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- F-260805 父子任务:子任务面板。仅顶层任务(无 parent_id)可作为父任务(1 级嵌套约束),
|
||||||
|
顶层无子时显示空态 + 添加入口(空树创建)。 -->
|
||||||
|
<section v-if="!task.parent_id" class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>{{ $t('taskDetail.childrenTitle') }}</h2>
|
||||||
|
<span class="subtask-count">{{ $t('taskDetail.subtaskCount', { n: children.length }) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 顶部进度条 + 计数(done+cancelled / total) -->
|
||||||
|
<div v-if="children.length > 0" class="subtask-progress">
|
||||||
|
<div class="mini-progress">
|
||||||
|
<div class="mini-progress-fill" :style="{ width: subtaskProgress.pct + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="subtask-progress-text">{{ $t('taskDetail.progressTitle') }}: {{ subtaskProgress.done }}/{{ subtaskProgress.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="children.length > 0" class="subtask-list">
|
||||||
|
<div
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
class="subtask-item"
|
||||||
|
@click="router.push(`/tasks/${child.id}`)"
|
||||||
|
>
|
||||||
|
<span class="subtask-title">{{ child.title }}</span>
|
||||||
|
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
|
||||||
|
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
|
||||||
|
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleChildMenu(child.id)">⚙️</button>
|
||||||
|
<!-- 子任务行快捷菜单(复用列表页 quickStatuses/quickPriorities 模式,advance 后刷新子列表) -->
|
||||||
|
<div v-if="childMenuId === child.id" class="quick-menu" @click.stop>
|
||||||
|
<div class="quick-menu-section">
|
||||||
|
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||||
|
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="advanceChild(child, s.key)">
|
||||||
|
<span>{{ s.icon }}</span>{{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="quick-menu-divider"></div>
|
||||||
|
<div class="quick-menu-section">
|
||||||
|
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||||
|
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="setChildPriority(child, p.value)">
|
||||||
|
<span :class="p.cls">●</span>{{ p.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 空态:父任务无子任务 -->
|
||||||
|
<div v-else class="empty-hint subtask-empty">{{ $t('taskDetail.childEmpty') }}</div>
|
||||||
|
<button class="btn btn-ghost btn-sm subtask-add" type="button" @click="openSubtaskModal">{{ $t('taskDetail.addSubtask') }}</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
|
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
|
||||||
@@ -144,13 +199,41 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- F-260805:子任务新建小弹窗(标题 + 优先级 + 可选描述),project_id/parent_id 继承当前任务 -->
|
||||||
|
<div class="modal-overlay" v-if="showSubtaskModal" @click.self="showSubtaskModal = false">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>{{ $t('taskDetail.addSubtask') }}</h3>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('taskDetail.title') }}</label>
|
||||||
|
<input v-model="subtaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmSubtask" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('taskDetail.description') }}</label>
|
||||||
|
<input v-model="subtaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('taskDetail.priority') }}</label>
|
||||||
|
<select v-model="subtaskPriority">
|
||||||
|
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option>
|
||||||
|
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option>
|
||||||
|
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option>
|
||||||
|
<option :value="3">{{ $t('tasks.modal.priorityLow') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-ghost" @click="showSubtaskModal = false">{{ $t('common.cancel') }}</button>
|
||||||
|
<button class="btn btn-primary" @click="confirmSubtask" :disabled="subtaskSubmitting || !subtaskTitle.trim()">{{ $t('common.confirm') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import { taskApi, projectApi, ideaApi } from '@/api'
|
import { taskApi, projectApi, ideaApi } from '@/api'
|
||||||
import { workflowApi } from '@/api'
|
import { workflowApi } from '@/api'
|
||||||
@@ -169,6 +252,7 @@ import type { NodeStatus } from '@/components/workflow/WorkflowDagDisplay.vue'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -178,6 +262,126 @@ const projects = ref<ProjectRecord[]>([])
|
|||||||
const ideas = ref<IdeaRecord[]>([])
|
const ideas = ref<IdeaRecord[]>([])
|
||||||
const advancing = ref(false)
|
const advancing = ref(false)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// F-260805 父子任务:父面包屑 + 子任务面板
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 本视图绕 store 直调 taskApi,父子数据本地维护:
|
||||||
|
// - 子任务(parent_id 有值):额外取父任务标题供面包屑
|
||||||
|
// - 顶层任务(无 parent_id):拉子任务列表 + 父进度(前端计算),advance/改优先级后手动刷新
|
||||||
|
const children = ref<TaskRecord[]>([])
|
||||||
|
const parentTaskTitle = ref<string | null>(null)
|
||||||
|
// 子任务行快捷菜单(当前打开的子任务 id)
|
||||||
|
const childMenuId = ref<string | null>(null)
|
||||||
|
function toggleChildMenu(id: string) {
|
||||||
|
childMenuId.value = childMenuId.value === id ? null : id
|
||||||
|
}
|
||||||
|
function closeChildMenu() { childMenuId.value = null }
|
||||||
|
|
||||||
|
// 复用列表页 quickStatuses/quickPriorities 模式(子任务行快捷推进)
|
||||||
|
const quickStatuses = computed(() => [
|
||||||
|
{ key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') },
|
||||||
|
{ key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') },
|
||||||
|
{ key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') },
|
||||||
|
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
|
||||||
|
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
|
||||||
|
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
|
||||||
|
])
|
||||||
|
const quickPriorities = computed(() => [
|
||||||
|
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
|
||||||
|
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
|
||||||
|
{ value: 2, label: t('tasks.modal.priorityMedium'), cls: 'priority-medium' },
|
||||||
|
{ value: 3, label: t('tasks.modal.priorityLow'), cls: 'priority-low' },
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 子任务父进度 = children 中 done+cancelled / total(仅顶层任务有 children) */
|
||||||
|
const subtaskProgress = computed(() => {
|
||||||
|
const total = children.value.length
|
||||||
|
const done = children.value.filter(c => c.status === 'done' || c.status === 'cancelled').length
|
||||||
|
return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 拉取当前任务的直接子任务(仅顶层任务可作父,1 级嵌套) */
|
||||||
|
async function loadChildren() {
|
||||||
|
const cur = task.value
|
||||||
|
if (!cur || cur.parent_id) { children.value = []; return }
|
||||||
|
try {
|
||||||
|
children.value = await taskApi.list({ project_id: cur.project_id, parent_id: cur.id })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TaskDetail] 拉取子任务失败:', e)
|
||||||
|
children.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取父任务标题(父面包屑用;取不到时回退显示父 id) */
|
||||||
|
async function loadParentTitle() {
|
||||||
|
const cur = task.value
|
||||||
|
if (!cur || !cur.parent_id) { parentTaskTitle.value = null; return }
|
||||||
|
try {
|
||||||
|
const parent = await taskApi.get(cur.parent_id)
|
||||||
|
parentTaskTitle.value = parent.title
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TaskDetail] 拉取父任务标题失败:', e)
|
||||||
|
parentTaskTitle.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子任务快捷推进(advance 后刷新子列表,父进度随之更新) */
|
||||||
|
async function advanceChild(child: TaskRecord, target: string) {
|
||||||
|
childMenuId.value = null
|
||||||
|
try {
|
||||||
|
await taskApi.advance(child.id, target)
|
||||||
|
await loadChildren()
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子任务快捷改优先级(update 后刷新子列表) */
|
||||||
|
async function setChildPriority(child: TaskRecord, priority: number) {
|
||||||
|
childMenuId.value = null
|
||||||
|
try {
|
||||||
|
await taskApi.update(child.id, 'priority', String(priority))
|
||||||
|
await loadChildren()
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// F-260805:子任务新建小弹窗
|
||||||
|
const showSubtaskModal = ref(false)
|
||||||
|
const subtaskTitle = ref('')
|
||||||
|
const subtaskDesc = ref('')
|
||||||
|
const subtaskPriority = ref(2)
|
||||||
|
const subtaskSubmitting = ref(false)
|
||||||
|
function openSubtaskModal() {
|
||||||
|
subtaskTitle.value = ''
|
||||||
|
subtaskDesc.value = ''
|
||||||
|
subtaskPriority.value = 2
|
||||||
|
showSubtaskModal.value = true
|
||||||
|
}
|
||||||
|
async function confirmSubtask() {
|
||||||
|
const cur = task.value
|
||||||
|
if (!cur || !subtaskTitle.value.trim() || subtaskSubmitting.value) return
|
||||||
|
subtaskSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const r = await taskApi.create({
|
||||||
|
project_id: cur.project_id,
|
||||||
|
title: subtaskTitle.value.trim(),
|
||||||
|
description: subtaskDesc.value.trim() || undefined,
|
||||||
|
priority: subtaskPriority.value,
|
||||||
|
// project_id 继承当前任务、parent_id = 当前任务 id
|
||||||
|
parent_id: cur.id,
|
||||||
|
})
|
||||||
|
if (!r) return
|
||||||
|
showSubtaskModal.value = false
|
||||||
|
await loadChildren()
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = t('taskDetail.addSubtaskFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||||
|
} finally {
|
||||||
|
subtaskSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
|
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -458,6 +662,8 @@ async function load() {
|
|||||||
task.value = t
|
task.value = t
|
||||||
projects.value = ps
|
projects.value = ps
|
||||||
ideas.value = ideasList
|
ideas.value = ideasList
|
||||||
|
// F-260805:父子数据(子任务取父标题 / 顶层任务取子任务列表),与主数据并行
|
||||||
|
await Promise.all([loadParentTitle(), loadChildren()])
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
task.value = null
|
task.value = null
|
||||||
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||||
@@ -489,6 +695,8 @@ let _unlistenWorkflowEvent: (() => void) | null = null
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||||||
load()
|
load()
|
||||||
|
// F-260805:点击子任务快捷菜单外关闭菜单
|
||||||
|
document.addEventListener('click', closeChildMenu)
|
||||||
// B-260616-18: 后端 AI 工具(create/update/delete 等)emit df-data-changed → 本视图重载当前 task
|
// B-260616-18: 后端 AI 工具(create/update/delete 等)emit df-data-changed → 本视图重载当前 task
|
||||||
// entity=task:任务本体字段(标题/状态/描述/分支…)被改时刷新;entity=project:项目名变更影响 projectName 解析时刷新
|
// entity=task:任务本体字段(标题/状态/描述/分支…)被改时刷新;entity=project:项目名变更影响 projectName 解析时刷新
|
||||||
try {
|
try {
|
||||||
@@ -513,6 +721,8 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||||||
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
|
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
|
||||||
|
// F-260805:移除子任务快捷菜单关闭监听
|
||||||
|
document.removeEventListener('click', closeChildMenu)
|
||||||
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
|
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
|
||||||
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
||||||
})
|
})
|
||||||
@@ -683,7 +893,7 @@ onBeforeUnmount(() => {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
background: rgba(108, 99, 255, 0.06);
|
background: var(--df-accent-bg);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@@ -698,6 +908,117 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
.error-hint { color: var(--df-danger); }
|
.error-hint { color: var(--df-danger); }
|
||||||
|
|
||||||
|
/* ===== F-260805 父子任务:子任务面板 ===== */
|
||||||
|
.subtask-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.subtask-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
.subtask-progress-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 迷你进度条(与 Tasks.vue 树形列表同款,渐变填充) */
|
||||||
|
.mini-progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--df-border);
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mini-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
background: linear-gradient(90deg, var(--df-accent), var(--df-success));
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.subtask-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.subtask-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative; /* 快捷菜单绝对定位锚点 */
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.subtask-item:hover { background: var(--df-accent-bg); }
|
||||||
|
.subtask-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--df-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.subtask-quick-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.subtask-item:hover .subtask-quick-btn { opacity: 0.6; }
|
||||||
|
.subtask-quick-btn:hover { opacity: 1; }
|
||||||
|
/* 子任务快捷菜单(复用列表页 quick-menu 同款视觉) */
|
||||||
|
.quick-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||||
|
z-index: 20;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.quick-menu-section { padding: 4px 0; }
|
||||||
|
.quick-menu-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
padding: 2px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.quick-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--df-text);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.quick-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.quick-menu-divider {
|
||||||
|
height: 0.5px;
|
||||||
|
background: var(--df-border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.subtask-empty { padding: 16px 8px; }
|
||||||
|
.subtask-add { margin-top: 8px; }
|
||||||
|
|
||||||
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
|
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.detail-grid { grid-template-columns: 1fr; }
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
+312
-93
@@ -10,7 +10,7 @@
|
|||||||
:placeholder="$t('tasks.searchPlaceholder')"
|
:placeholder="$t('tasks.searchPlaceholder')"
|
||||||
@keyup.esc="searchKeyword = ''"
|
@keyup.esc="searchKeyword = ''"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-primary" @click="openCreateModal">{{ $t('tasks.create') }}</button>
|
<button class="btn btn-primary" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<div v-else-if="filteredGroups.length === 0" class="empty-state">
|
<div v-else-if="filteredGroups.length === 0" class="empty-state">
|
||||||
<div class="empty-icon">📋</div>
|
<div class="empty-icon">📋</div>
|
||||||
<div>{{ $t('tasks.group.empty') }}</div>
|
<div>{{ $t('tasks.group.empty') }}</div>
|
||||||
<button class="btn btn-primary btn-sm" @click="openCreateModal">{{ $t('tasks.create') }}</button>
|
<button class="btn btn-primary btn-sm" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section
|
<section
|
||||||
@@ -63,71 +63,181 @@
|
|||||||
<span class="group-chevron">{{ collapsedGroups.has(group.projectName) ? '▸' : '▾' }}</span>
|
<span class="group-chevron">{{ collapsedGroups.has(group.projectName) ? '▸' : '▾' }}</span>
|
||||||
<span class="group-icon">{{ group.icon }}</span>
|
<span class="group-icon">{{ group.icon }}</span>
|
||||||
<h2 class="group-name">{{ group.projectName }}</h2>
|
<h2 class="group-name">{{ group.projectName }}</h2>
|
||||||
<span class="group-count">{{ group.tasks.length }}</span>
|
<span class="group-count">{{ group.rows.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!collapsedGroups.has(group.projectName)" class="task-list">
|
<div v-show="!collapsedGroups.has(group.projectName)" class="task-list">
|
||||||
|
<template v-for="row in group.rows" :key="row.task.id">
|
||||||
|
<!-- 父任务行(有子任务):折叠箭头 + 📑 图标 + 子进度徽章 n/m + 迷你进度条 + 快捷菜单(含添加子任务)。
|
||||||
|
点击行跳详情;折叠箭头 @click.stop 只切换展开不跳转。 -->
|
||||||
<div
|
<div
|
||||||
class="task-item"
|
v-if="row.isParent"
|
||||||
v-for="task in group.tasks"
|
class="task-item task-item-parent"
|
||||||
:key="task.id"
|
:class="{ 'is-expanded': expandedParents.has(row.task.id) }"
|
||||||
@click="router.push(`/tasks/${task.id}`)"
|
@click="router.push(`/tasks/${row.task.id}`)"
|
||||||
>
|
>
|
||||||
<div class="task-main">
|
<div class="task-main">
|
||||||
<div class="task-title-row">
|
<div class="task-title-row">
|
||||||
<span class="task-title">{{ task.title }}</span>
|
<button
|
||||||
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
class="fold-btn"
|
||||||
|
:class="{ 'is-open': expandedParents.has(row.task.id) }"
|
||||||
|
@click.stop="toggleParent(row.task.id)"
|
||||||
|
>{{ expandedParents.has(row.task.id) ? '▾' : '▸' }}</button>
|
||||||
|
<span class="task-parent-icon">📑</span>
|
||||||
|
<span class="task-title">{{ row.task.title }}</span>
|
||||||
|
<span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span>
|
||||||
|
<!-- 子进度徽章(done+cancelled / total) -->
|
||||||
|
<span class="sub-progress-badge" :title="$t('tasks.tree.progress')">{{ row.progress!.done }}/{{ row.progress!.total }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-meta">
|
<div class="task-meta">
|
||||||
<span class="branch-tag" v-if="task.branch_name">
|
<span class="branch-tag" v-if="row.task.branch_name">
|
||||||
<span class="branch-icon">⑂</span>{{ task.branch_name }}
|
<span class="branch-icon">⑂</span>{{ row.task.branch_name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="task-date">{{ formatRelative(task.updated_at) }}</span>
|
<span class="task-date">{{ formatRelative(row.task.updated_at) }}</span>
|
||||||
|
<!-- 迷你进度条(渐变填充,宽度=完成子任务百分比) -->
|
||||||
|
<div class="mini-progress" :title="$t('tasks.tree.progress')">
|
||||||
|
<div class="mini-progress-fill" :style="{ width: parentPct(row) + '%' }"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
|
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
|
||||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(task.id)">⚙️</button>
|
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||||
<div v-if="quickMenuId === task.id" class="quick-menu" @click.stop>
|
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
|
||||||
<div class="quick-menu-section">
|
<div class="quick-menu-section">
|
||||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||||
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(task.id, s.key)">
|
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
|
||||||
<span>{{ s.icon }}</span>{{ s.label }}
|
<span>{{ s.icon }}</span>{{ s.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-menu-divider"></div>
|
<div class="quick-menu-divider"></div>
|
||||||
<div class="quick-menu-section">
|
<div class="quick-menu-section">
|
||||||
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(task.id, p.value)">
|
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(row.task.id, p.value)">
|
||||||
<span :class="p.cls">●</span>{{ p.label }}
|
<span :class="p.cls">●</span>{{ p.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-menu-divider"></div>
|
<div class="quick-menu-divider"></div>
|
||||||
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(task)">{{ $t('tasks.quickDelete') }}</button>
|
<!-- F-260805:父任务快捷菜单「添加子任务」(预填父任务打开新建弹窗) -->
|
||||||
|
<button class="quick-menu-item" @click="openCreateModal(row.task)">
|
||||||
|
<span>+</span>{{ $t('tasks.addSubtask') }}
|
||||||
|
</button>
|
||||||
|
<div class="quick-menu-divider"></div>
|
||||||
|
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(row.task, row.children.length)">{{ $t('tasks.quickDelete') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 子任务行(父展开时 v-show 渲染):缩进 + 左侧竖线引导线 + 行首圆点连接符。
|
||||||
|
常规快捷操作与顶层一致;点击跳 /tasks/{child.id}。 -->
|
||||||
|
<div
|
||||||
|
v-for="child in row.children"
|
||||||
|
v-show="expandedParents.has(row.task.id)"
|
||||||
|
:key="child.id"
|
||||||
|
class="task-item task-item-child"
|
||||||
|
@click="router.push(`/tasks/${child.id}`)"
|
||||||
|
>
|
||||||
|
<div class="task-main">
|
||||||
|
<div class="task-title-row">
|
||||||
|
<span class="child-dot">•</span>
|
||||||
|
<span class="task-title">{{ child.title }}</span>
|
||||||
|
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="branch-tag" v-if="child.branch_name">
|
||||||
|
<span class="branch-icon">⑂</span>{{ child.branch_name }}
|
||||||
|
</span>
|
||||||
|
<span class="task-date">{{ formatRelative(child.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(statusLabel(child.status)) }}</span>
|
||||||
|
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)">⚙️</button>
|
||||||
|
<div v-if="quickMenuId === child.id" class="quick-menu" @click.stop>
|
||||||
|
<div class="quick-menu-section">
|
||||||
|
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||||
|
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(child.id, s.key)">
|
||||||
|
<span>{{ s.icon }}</span>{{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="quick-menu-divider"></div>
|
||||||
|
<div class="quick-menu-section">
|
||||||
|
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||||
|
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(child.id, p.value)">
|
||||||
|
<span :class="p.cls">●</span>{{ p.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="quick-menu-divider"></div>
|
||||||
|
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(child)">{{ $t('tasks.quickDelete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶层任务无子(普通行) -->
|
||||||
|
<div
|
||||||
|
v-if="!row.isParent"
|
||||||
|
class="task-item"
|
||||||
|
@click="router.push(`/tasks/${row.task.id}`)"
|
||||||
|
>
|
||||||
|
<div class="task-main">
|
||||||
|
<div class="task-title-row">
|
||||||
|
<span class="task-title">{{ row.task.title }}</span>
|
||||||
|
<span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="branch-tag" v-if="row.task.branch_name">
|
||||||
|
<span class="branch-icon">⑂</span>{{ row.task.branch_name }}
|
||||||
|
</span>
|
||||||
|
<span class="task-date">{{ formatRelative(row.task.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
|
||||||
|
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||||
|
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
|
||||||
|
<div class="quick-menu-section">
|
||||||
|
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||||
|
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
|
||||||
|
<span>{{ s.icon }}</span>{{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="quick-menu-divider"></div>
|
||||||
|
<div class="quick-menu-section">
|
||||||
|
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||||
|
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(row.task.id, p.value)">
|
||||||
|
<span :class="p.cls">●</span>{{ p.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="quick-menu-divider"></div>
|
||||||
|
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(row.task)">{{ $t('tasks.quickDelete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页器 -->
|
<!-- F-260805 D5:列表页改一次性加载 + 前端组装树,移除真分页 Paginator(分页会割裂父/子) -->
|
||||||
<Paginator
|
|
||||||
v-model:page="page"
|
|
||||||
v-model:pageSize="pageSize"
|
|
||||||
:total="totalTasks"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 新建任务模态框(统一样式,去内联 style) -->
|
<!-- 新建任务模态框(统一样式,去内联 style) -->
|
||||||
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
|
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3>{{ $t('tasks.modal.title') }}</h3>
|
<h3>{{ $t('tasks.modal.title') }}</h3>
|
||||||
<div class="modal-field">
|
<div class="modal-field">
|
||||||
<label>{{ $t('tasks.modal.project') }}</label>
|
<label>{{ $t('tasks.modal.project') }}</label>
|
||||||
<select v-model="newTaskProjectId">
|
<!-- F-260805:选择父任务后 project 锁定为该父所属项目(disabled),需先选回「无」再切项目 -->
|
||||||
|
<select v-model="newTaskProjectId" :disabled="!!newTaskParentId" @change="newTaskParentId = ''">
|
||||||
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- F-260805:父任务下拉(选项=当前选中项目的顶层任务 + 首项「无」;提交透传 parent_id,空=顶层任务) -->
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('tasks.modal.parentTask') }}</label>
|
||||||
|
<select v-model="newTaskParentId">
|
||||||
|
<option value="">{{ $t('tasks.modal.parentPlaceholder') }}</option>
|
||||||
|
<option v-for="p in parentTaskOptions" :key="p.id" :value="p.id">{{ p.title }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="modal-field">
|
<div class="modal-field">
|
||||||
<label>{{ $t('tasks.modal.titleField') }}</label>
|
<label>{{ $t('tasks.modal.titleField') }}</label>
|
||||||
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
|
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
|
||||||
@@ -178,7 +288,6 @@ import { formatRelative } from '@/utils/time'
|
|||||||
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
||||||
import { taskApi } from '@/api'
|
import { taskApi } from '@/api'
|
||||||
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
|
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
|
||||||
import Paginator from '../components/Paginator.vue'
|
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { usePersistedRef } from '@/composables/usePersistedRef'
|
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||||
@@ -196,14 +305,28 @@ const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
|
|||||||
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
|
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 分页(默认开启 20 条/页)
|
// F-260805 D5:列表页一次性加载 + 前端组装树,移除真分页(page/pageSize/totalTasks 已删)。
|
||||||
const page = usePersistedRef('tasks.page', 1)
|
|
||||||
const pageSize = usePersistedRef('tasks.pageSize', 20)
|
|
||||||
const totalTasks = ref(0)
|
|
||||||
|
|
||||||
// 分组折叠状态(localStorage 记忆)
|
// 分组折叠状态(localStorage 记忆)
|
||||||
const collapsedGroups = reactive(new Set<string>())
|
const collapsedGroups = reactive(new Set<string>())
|
||||||
|
|
||||||
|
// F-260805:父任务展开/折叠状态(localStorage 记忆,沿用 collapsedGroups 模式)
|
||||||
|
const expandedParents = reactive(new Set<string>())
|
||||||
|
function toggleParent(id: string) {
|
||||||
|
if (expandedParents.has(id)) {
|
||||||
|
expandedParents.delete(id)
|
||||||
|
} else {
|
||||||
|
expandedParents.add(id)
|
||||||
|
}
|
||||||
|
localStorage.setItem('df-tasks-expanded', JSON.stringify([...expandedParents]))
|
||||||
|
}
|
||||||
|
// 恢复父任务展开记忆(脏数据静默忽略)
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('df-tasks-expanded')
|
||||||
|
if (saved) {
|
||||||
|
for (const id of JSON.parse(saved)) expandedParents.add(id)
|
||||||
|
}
|
||||||
|
} catch { /* 忽略脏数据 */ }
|
||||||
|
|
||||||
// 快捷菜单
|
// 快捷菜单
|
||||||
const quickMenuId = ref<string | null>(null)
|
const quickMenuId = ref<string | null>(null)
|
||||||
function toggleQuickMenu(id: string) {
|
function toggleQuickMenu(id: string) {
|
||||||
@@ -237,9 +360,13 @@ async function quickPriority(id: string, priority: number) {
|
|||||||
await store.loadTasks(buildTaskQuery())
|
await store.loadTasks(buildTaskQuery())
|
||||||
} catch (e) { console.error('快捷改优先级失败:', e) }
|
} catch (e) { console.error('快捷改优先级失败:', e) }
|
||||||
}
|
}
|
||||||
async function quickDelete(task: TaskRecord) {
|
async function quickDelete(task: TaskRecord, childCount = 0) {
|
||||||
quickMenuId.value = null
|
quickMenuId.value = null
|
||||||
if (!await confirmDialog(t('tasks.confirmDelete', { title: task.title }))) return
|
// F-260805:父任务带子任务时确认文案含子任务数(后端级联软删)
|
||||||
|
const msg = childCount > 0
|
||||||
|
? t('tasks.confirmDeleteWithChildren', { title: task.title, n: childCount })
|
||||||
|
: t('tasks.confirmDelete', { title: task.title })
|
||||||
|
if (!await confirmDialog(msg)) return
|
||||||
try {
|
try {
|
||||||
await store.deleteTask(task.id)
|
await store.deleteTask(task.id)
|
||||||
} catch (e) { console.error('删除失败:', e) }
|
} catch (e) { console.error('删除失败:', e) }
|
||||||
@@ -267,12 +394,8 @@ let _searchTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
watch(searchKeyword, () => {
|
watch(searchKeyword, () => {
|
||||||
if (_searchTimer) clearTimeout(_searchTimer)
|
if (_searchTimer) clearTimeout(_searchTimer)
|
||||||
_searchTimer = setTimeout(() => {
|
_searchTimer = setTimeout(() => {
|
||||||
page.value = 1
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
Promise.all([
|
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
|
||||||
store.loadTasks(buildTaskQuery()),
|
|
||||||
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
|
|
||||||
]).finally(() => { loading.value = false })
|
|
||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -283,14 +406,25 @@ const newTaskTitle = ref('')
|
|||||||
const newTaskDesc = ref('')
|
const newTaskDesc = ref('')
|
||||||
const newTaskBranch = ref('')
|
const newTaskBranch = ref('')
|
||||||
const newTaskPriority = ref(2)
|
const newTaskPriority = ref(2)
|
||||||
|
// F-260805:父任务下拉选中值(空串=无/顶层任务)
|
||||||
|
const newTaskParentId = ref('')
|
||||||
// F-260619-01:关联灵感(空串=不关联,与后端 idea_id 空串处理一致)
|
// F-260619-01:关联灵感(空串=不关联,与后端 idea_id 空串处理一致)
|
||||||
const newTaskIdeaId = ref('')
|
const newTaskIdeaId = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
interface TaskGroup {
|
// F-260805:父任务选项 = 当前选中项目的顶层任务(无 parent_id;仅顶层可作父,1 级嵌套)
|
||||||
projectName: string
|
const parentTaskOptions = computed(() =>
|
||||||
icon: string
|
store.tasks.filter(t => !t.parent_id && t.project_id === newTaskProjectId.value),
|
||||||
tasks: TaskRecord[]
|
)
|
||||||
|
|
||||||
|
// F-260805:树形行结构(父任务 + 直接子任务 + 子进度)。1 级嵌套,无递归。
|
||||||
|
interface TaskRow {
|
||||||
|
task: TaskRecord
|
||||||
|
/** 父任务的直接子任务(仅父有;叶子为空数组) */
|
||||||
|
children: TaskRecord[]
|
||||||
|
/** 父任务子进度(done+cancelled / total;叶子为 undefined) */
|
||||||
|
progress?: { done: number; total: number }
|
||||||
|
isParent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
|
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
|
||||||
@@ -314,33 +448,70 @@ const projectIcons: Record<string, string> = {
|
|||||||
'flux': '⚡',
|
'flux': '⚡',
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredGroups = computed(() => {
|
interface TaskGroup {
|
||||||
// 后端已分页(limit/offset),store.tasks 是当前页数据,直接分组无需再 slice
|
projectName: string
|
||||||
const sorted = [...store.tasks]
|
icon: string
|
||||||
sorted.sort((a: any, b: any) => {
|
/** 组内顶层任务行(子任务内嵌在行.children) */
|
||||||
|
rows: TaskRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// F-260805:树组装 — 顶层 = 无 parent_id 的任务,每个顶层挂 children;父进度前端计算(D6,全量已在前端)。
|
||||||
|
const taskRows = computed<TaskRow[]>(() => {
|
||||||
|
// 子任务按 parent_id 分组(1 级嵌套)
|
||||||
|
const childMap = new Map<string, TaskRecord[]>()
|
||||||
|
for (const t of store.tasks) {
|
||||||
|
if (!t.parent_id) continue
|
||||||
|
const arr = childMap.get(t.parent_id) ?? []
|
||||||
|
arr.push(t)
|
||||||
|
childMap.set(t.parent_id, arr)
|
||||||
|
}
|
||||||
|
// 排序沿用现有 sortBy 逻辑(顶层 + 子级同一比较器)
|
||||||
|
const cmp = (a: TaskRecord, b: TaskRecord): number => {
|
||||||
if (sortBy.value === 'priority') return (a.priority ?? 2) - (b.priority ?? 2)
|
if (sortBy.value === 'priority') return (a.priority ?? 2) - (b.priority ?? 2)
|
||||||
if (sortBy.value === 'status') return String(a.status).localeCompare(String(b.status))
|
if (sortBy.value === 'status') return String(a.status).localeCompare(String(b.status))
|
||||||
const av = a[sortBy.value] ?? ''
|
const av = (a as any)[sortBy.value] ?? ''
|
||||||
const bv = b[sortBy.value] ?? ''
|
const bv = (b as any)[sortBy.value] ?? ''
|
||||||
return String(bv).localeCompare(String(av))
|
return String(bv).localeCompare(String(av))
|
||||||
})
|
|
||||||
|
|
||||||
// 按项目分组
|
|
||||||
const groupMap = new Map<string, TaskRecord[]>()
|
|
||||||
for (const task of sorted) {
|
|
||||||
if (!groupMap.has(task.project_id)) {
|
|
||||||
groupMap.set(task.project_id, [])
|
|
||||||
}
|
}
|
||||||
groupMap.get(task.project_id)!.push(task)
|
const tops = store.tasks.filter(t => !t.parent_id).sort(cmp)
|
||||||
|
const rows: TaskRow[] = []
|
||||||
|
for (const t of tops) {
|
||||||
|
const children = (childMap.get(t.id) ?? []).sort(cmp)
|
||||||
|
const isParent = children.length > 0
|
||||||
|
const done = children.filter(c => c.status === 'done' || c.status === 'cancelled').length
|
||||||
|
rows.push({
|
||||||
|
task: t,
|
||||||
|
children,
|
||||||
|
isParent,
|
||||||
|
progress: isParent ? { done, total: children.length } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 父任务迷你进度条百分比(done/total) */
|
||||||
|
function parentPct(row: TaskRow): number {
|
||||||
|
if (!row.progress || row.progress.total === 0) return 0
|
||||||
|
return Math.round((row.progress.done / row.progress.total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
// F-260805 D5:store.tasks 是一次性拉取的筛选全量,前端已组装树(taskRows),直接按项目分组
|
||||||
|
const groupMap = new Map<string, TaskRow[]>()
|
||||||
|
for (const row of taskRows.value) {
|
||||||
|
if (!groupMap.has(row.task.project_id)) {
|
||||||
|
groupMap.set(row.task.project_id, [])
|
||||||
|
}
|
||||||
|
groupMap.get(row.task.project_id)!.push(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: TaskGroup[] = []
|
const result: TaskGroup[] = []
|
||||||
for (const [projectId, groupTasks] of groupMap) {
|
for (const [projectId, rows] of groupMap) {
|
||||||
const name = getProjectName(projectId)
|
const name = getProjectName(projectId)
|
||||||
result.push({
|
result.push({
|
||||||
projectName: name,
|
projectName: name,
|
||||||
icon: projectIcons[name] || '📂',
|
icon: projectIcons[name] || '📂',
|
||||||
tasks: groupTasks,
|
rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -354,16 +525,23 @@ function buildTaskQuery(): TaskQuery | undefined {
|
|||||||
if (!projAll) query.project_id = activeProject.value
|
if (!projAll) query.project_id = activeProject.value
|
||||||
if (!statusAll) query.status = activeStatus.value
|
if (!statusAll) query.status = activeStatus.value
|
||||||
if (kw) query.keyword = kw
|
if (kw) query.keyword = kw
|
||||||
// 后端真分页:传 limit/offset,配合 count_by_query 获取 total
|
// F-260805 D5:一次拉当前筛选全量(limit 放大 500 钳制上限,offset 恒 0),
|
||||||
if (pageSize.value > 0) {
|
// 树形需要完整父子关系,分页会割裂父/子;全量已在前端,父进度也前端计算
|
||||||
query.limit = pageSize.value
|
query.limit = 500
|
||||||
query.offset = (page.value - 1) * pageSize.value
|
query.offset = 0
|
||||||
}
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
// F-260805:打开新建弹窗。可选 parent(父任务快捷菜单「添加子任务」):
|
||||||
|
// 有父 → project_id 锁定为该父所属项目、parent_id 预填;无父(顶部「新建任务」)→ 默认父=无(顶层任务)。
|
||||||
|
function openCreateModal(parent?: TaskRecord) {
|
||||||
|
if (parent) {
|
||||||
|
newTaskParentId.value = parent.id
|
||||||
|
newTaskProjectId.value = parent.project_id
|
||||||
|
} else {
|
||||||
|
newTaskParentId.value = ''
|
||||||
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
|
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
|
||||||
|
}
|
||||||
newTaskTitle.value = ''
|
newTaskTitle.value = ''
|
||||||
newTaskDesc.value = ''
|
newTaskDesc.value = ''
|
||||||
newTaskBranch.value = ''
|
newTaskBranch.value = ''
|
||||||
@@ -384,6 +562,8 @@ async function confirmCreate() {
|
|||||||
priority: newTaskPriority.value,
|
priority: newTaskPriority.value,
|
||||||
// F-260619-01:空串=不关联(对齐后端 idea_id 空串语义)
|
// F-260619-01:空串=不关联(对齐后端 idea_id 空串语义)
|
||||||
idea_id: newTaskIdeaId.value.trim() || undefined,
|
idea_id: newTaskIdeaId.value.trim() || undefined,
|
||||||
|
// F-260805:父任务透传(空串=顶层任务,后端视为 None)
|
||||||
|
parent_id: newTaskParentId.value || undefined,
|
||||||
})
|
})
|
||||||
if (!r) return
|
if (!r) return
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
@@ -395,40 +575,19 @@ async function confirmCreate() {
|
|||||||
// 筛选切换重载
|
// 筛选切换重载
|
||||||
watch(activeProject, () => {
|
watch(activeProject, () => {
|
||||||
store.setActiveTaskProject(activeProject.value)
|
store.setActiveTaskProject(activeProject.value)
|
||||||
page.value = 1
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
Promise.all([
|
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
|
||||||
store.loadTasks(buildTaskQuery()),
|
|
||||||
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
|
|
||||||
]).finally(() => { loading.value = false })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(activeStatus, () => {
|
watch(activeStatus, () => {
|
||||||
store.setActiveTaskStatus(activeStatus.value)
|
store.setActiveTaskStatus(activeStatus.value)
|
||||||
page.value = 1
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
Promise.all([
|
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
|
||||||
store.loadTasks(buildTaskQuery()),
|
|
||||||
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
|
|
||||||
]).finally(() => { loading.value = false })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(sortBy, () => {
|
watch(sortBy, () => {
|
||||||
page.value = 1
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
Promise.all([
|
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
|
||||||
store.loadTasks(buildTaskQuery()),
|
|
||||||
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
|
|
||||||
]).finally(() => { loading.value = false })
|
|
||||||
})
|
|
||||||
|
|
||||||
// 分页翻页/改每页条数:重新查询后端
|
|
||||||
watch([page, pageSize], () => {
|
|
||||||
loading.value = true
|
|
||||||
Promise.all([
|
|
||||||
store.loadTasks(buildTaskQuery()),
|
|
||||||
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
|
|
||||||
]).finally(() => { loading.value = false })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键)
|
// Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键)
|
||||||
@@ -449,10 +608,10 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
store.loadProjects(),
|
store.loadProjects(),
|
||||||
store.loadTasks(),
|
// F-260805 D5:一次拉当前筛选全量(limit 500),树形需要完整父子关系
|
||||||
|
store.loadTasks(buildTaskQuery()),
|
||||||
// F-260619-01:加载灵感供新建任务模态关联下拉选择(幂等,已加载则 no-op)
|
// F-260619-01:加载灵感供新建任务模态关联下拉选择(幂等,已加载则 no-op)
|
||||||
store.loadIdeas(),
|
store.loadIdeas(),
|
||||||
taskApi.count().then(n => totalTasks.value = n),
|
|
||||||
])
|
])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -546,7 +705,7 @@ onUnmounted(() => {
|
|||||||
border-bottom: 0.5px solid var(--df-border);
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.group-header:hover { background: rgba(108, 99, 255, 0.04); }
|
.group-header:hover { background: var(--df-accent-bg); }
|
||||||
.task-group.collapsed .group-header { border-bottom: none; }
|
.task-group.collapsed .group-header { border-bottom: none; }
|
||||||
.group-chevron { font-size: 10px; color: var(--df-text-dim); width: 12px; text-align: center; }
|
.group-chevron { font-size: 10px; color: var(--df-text-dim); width: 12px; text-align: center; }
|
||||||
.group-icon { font-size: 16px; }
|
.group-icon { font-size: 16px; }
|
||||||
@@ -574,7 +733,7 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.task-item:last-child { border-bottom: none; }
|
.task-item:last-child { border-bottom: none; }
|
||||||
.task-item:hover { background: rgba(108, 99, 255, 0.04); }
|
.task-item:hover { background: var(--df-accent-bg); }
|
||||||
|
|
||||||
.task-main { flex: 1; min-width: 0; }
|
.task-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
@@ -649,6 +808,66 @@ onUnmounted(() => {
|
|||||||
.task-item:hover .task-quick-btn { opacity: 0.6; }
|
.task-item:hover .task-quick-btn { opacity: 0.6; }
|
||||||
.task-quick-btn:hover { opacity: 1; }
|
.task-quick-btn:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* ===== F-260805 父子任务树形 ===== */
|
||||||
|
/* 折叠箭头(仅父任务行,点击只切展开不跳详情) */
|
||||||
|
.fold-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
width: 12px;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.fold-btn:hover { color: var(--df-accent); }
|
||||||
|
/* 父任务图标(与子任务区分) */
|
||||||
|
.task-parent-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* 子进度徽章 n/m(父任务行) */
|
||||||
|
.sub-progress-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 迷你进度条(细条渐变填充,宽度=完成子任务百分比) */
|
||||||
|
.mini-progress {
|
||||||
|
width: 72px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--df-border);
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mini-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
background: linear-gradient(90deg, var(--df-accent), var(--df-success));
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
/* 子任务行:缩进 + 左侧竖线引导线(延续父任务) */
|
||||||
|
.task-item-child {
|
||||||
|
padding-left: 32px;
|
||||||
|
border-left: 0.5px solid var(--df-border);
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
/* 行首圆点连接符(子任务) */
|
||||||
|
.child-dot {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-menu {
|
.quick-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
|
|||||||
Reference in New Issue
Block a user