修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复

This commit is contained in:
lxy
2026-08-12 00:29:06 +08:00
parent 9407f821b9
commit d31a27512c
36 changed files with 2614 additions and 264 deletions
+2 -2
View File
@@ -109,10 +109,10 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 idea_id 关联灵感、queue 管理池(默认 todo,新建仅 backlog/todo/decision)、parent_id 父任务 ID(限 1 级嵌套,父任务自身不能是子任务)、content_json 结构化需求规格(须合法 JSON))", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 2=medium,值域 0..=3)"), "idea_id": opt_str_field("关联灵感 ID(可空)"), "queue": opt_str_field("管理池(可空,默认 todo,新建仅 backlog/todo/decision)"), "parent_id": opt_str_field("父任务 ID(可空,限 1 级嵌套)"), "content_json": opt_str_field("结构化需求规格 JSON(可空,须合法 JSON)")}), &["project_id", "title"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled/deferred)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
+11 -3
View File
@@ -401,15 +401,23 @@ fn normalize_verdict_in_place(v: &mut serde_json::Value) {
/// 原地 clamp dimensions.*.score 到 [0,10]。只处理 number 类型,跳过非 number(留原值,
/// serde 反序列化由调用方按 schema 容错)。
///
/// 类型保真:合法范围内的整数(如 9)保持整数原样,不转 f64(9 → 9.0,污染下游展示与
/// 断言;测试 parse_review_json_valid_passes_through 锁定 9 应为整数)。仅越界值改写
/// (99/-1 → clamp 到 10/0)。
fn clamp_dimension_scores_in_place(v: &mut serde_json::Value) {
let Some(obj) = v.as_object_mut() else { return };
let Some(dims) = obj.get_mut("dimensions").and_then(|d| d.as_object_mut()) else { return };
for (_, dim) in dims.iter_mut() {
let Some(dim_obj) = dim.as_object_mut() else { continue };
if let Some(score) = dim_obj.get_mut("score").and_then(|s| s.as_f64()) {
let clamped = score.clamp(0.0, 10.0);
dim_obj.insert("score".into(), serde_json::json!(clamped));
let Some(score) = dim_obj.get("score") else { continue };
let Some(f) = score.as_f64() else { continue };
// 合法范围 [0,10] 内:保留原值(整数仍是整数,浮点仍是浮点),仅越界才改写 clamp 值。
if (0.0..=10.0).contains(&f) {
continue;
}
let clamped = f.clamp(0.0, 10.0);
dim_obj.insert("score".into(), serde_json::json!(clamped));
}
}
+7 -5
View File
@@ -62,7 +62,7 @@ impl AiSelfReviewNode {
let output = truncate_for_review_input(output_text);
format!(
"\
<task_requirements> <task_output> ,, \
`<task_requirements>` `<task_output>` ,, \
(//),, \
@@ -75,7 +75,7 @@ impl AiSelfReviewNode {
</task_output>
##
1. : <task_requirements>
1. : `<task_requirements>`
2. :
3. ://
4. :
@@ -481,9 +481,9 @@ mod tests {
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(10.0), "score 99 应 clamp 到 10");
// -5 → 0(下界)
assert_eq!(v["dimensions"]["completeness"]["score"], json!(0.0), "score -5 应 clamp 到 0");
// 区间内值不变
// 区间内值不变(类型保真:整数保持整数,不转 f64)
assert_eq!(v["dimensions"]["correctness"]["score"], json!(7.5), "score 7.5 区间内不变");
assert_eq!(v["dimensions"]["boundary"]["score"], json!(10.0), "score 10 边界值不变");
assert_eq!(v["dimensions"]["boundary"]["score"], json!(10), "score 10 边界值不变且保持整数");
}
/// P2-加固3:LLM 前置解释文字 + JSON,正则兜底提取首个 { 到末 } 解析成功。
@@ -647,8 +647,10 @@ mod tests {
"超长输入应被截断并标记"
);
// 截断后单个标签内字符数应受控(开闭标签之间 <= 2000 + 截断标记)
// 注意:模板说明文字也引用 `task_requirements`(行内反引号形式),find 首现会误命中
// 行内引用而非真实标签。真实标签独占一行(`\n<task_requirements>\n`),用换行锚定定位。
for tag in ["task_requirements", "task_output"] {
let open = format!("<{tag}>");
let open = format!("\n<{tag}>\n");
let close = format!("</{tag}>");
let start = p.find(&open).unwrap() + open.len();
let end = p.find(&close).unwrap();
+128 -41
View File
@@ -27,11 +27,11 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A
/// 入参:
/// - `repo`:df-storage TaskRepo
/// - `id`:任务 ID
/// - `target_status`:目标状态(7 态之一,snake_case)
/// - `target_status`:目标状态(8 态之一,snake_case)
///
/// 流程:
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
/// 2. target_status 合法性:非 8 态 → Validation 错误(防脏数据直入推进链)
/// 3. 状态机(三类拒绝,错误区分供前端分辨):
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
@@ -46,7 +46,7 @@ pub async fn advance_task_atomic(
id: &str,
target_status: &str,
) -> df_types::error::Result<TaskRecord> {
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
// 1. target 合法性(8 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
if !is_valid_state(target_status) {
return Err(df_types::error::Error::Validation(format!(
"非法 target_status {:?},合法值: {}",
@@ -66,24 +66,36 @@ pub async fn advance_task_atomic(
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
// - 两类错误均附加 legal_targets(from) 合法目标列表(当前态→可去态),
// 让 LLM 下次直接选对目标态,降低状态机拒绝的往返次数(AC-5 机制降失败)。
// - 非法转换额外附最短路径(如"deferred→todo→in_progress→blocked"),比
// 纯合法目标列表更直观(SC-260811-P1-1 状态机最短路径推导)。
let from = current.status.as_str();
let legal_hint = |from: &str| -> String {
let legal_hint = |from: &str, to_hint: Option<&str>| -> String {
let legal = crate::task_state_machine::legal_targets(from);
if legal.is_empty() {
let base = if legal.is_empty() {
format!("{from} 是终态, 无合法后继")
} else {
format!("{from} 的合法目标: {}", legal.join("/"))
};
// 当调用方明确知道目标态(非法转换分支),附加最短路径。
if let Some(target) = to_hint {
let path = crate::task_state_machine::shortest_path(from, target);
if path.len() > 2 {
// 路径比 2 长说明需要多跳,附路径提示。
return format!("{}, 合法路径: {}", base, crate::task_state_machine::format_path(&path));
}
}
base
};
if from == target_status {
return Err(df_types::error::Error::Validation(format!(
"相同状态 {from:?},无需推进,{}",
legal_hint(from)
legal_hint(from, None)
)));
}
if !can_transition(from, target_status) {
let hint = legal_hint(from, Some(target_status));
return Err(df_types::error::Error::InvalidState {
current: format!("{from}→{target_status}(非法状态转换), {}", legal_hint(from)),
current: format!("{from}→{target_status}(非法状态转换), {hint}"),
expected: target_status.to_string(),
});
}
@@ -102,24 +114,56 @@ pub async fn advance_task_atomic(
}
// ============================================================
// 父任务聚合 — 父任务 status 重算 + 推进联动(知识图谱 Phase 1 V29,设计 §2.1)
// 父任务聚合 — 父任务 status 重算 + 推进联动(容器模型,设计 §2.1)
// ============================================================
/// 父任务 status 重算(容器模型,不走状态机)。
/// 父状态聚合纯函数(全状态集完备划分,与 recompute_parent_status 解耦以便测试)。
///
/// 聚合规则(设计 §2.1 父聚合规则,优先级从高到低):
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
/// 2. 任一子 in_progress → 父 in_progress(执行中)
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
/// 4. 全子 todo → 父 todo(尚未开始)
/// 5. 其他混合态(如 todo+done)→ 父 in_progress(进行中,有进展未全完)
/// `counts`:子任务 status→数量;`total`:子任务总数(counts 各值之和,调用方已算好)。
///
/// 划分口径(不再手数固定几个桶,而是按语义类归并全部 8 态,避免加态漏改):
/// - 活跃态 active = {in_progress, in_review, testing} —— 有子任务正在推进
/// - 阻塞态 = blocked
/// - 收尾态 settled = {done, cancelled} —— 已了结(完成/取消)
/// - 静置态 = {todo, deferred} —— 尚未开始/暂缓
///
/// 判定优先级(高→低,首个命中即定):
/// 1. 任一 blocked → blocked(阻塞优先,不被其他态掩盖卡点)
/// 2. 任一 active → in_progress(有子在推进)
/// 3. 全部 settled → done(全部了结)
/// 4. 全部 todo → todo(整体尚未开始)
/// 5. 全部 deferred → deferred(整体暂缓,不再误报 in_progress)
/// 6. 其余静置/收尾混合(todo+deferred / todo+done / deferred+done 等,无 active/blocked)
/// → in_progress(有进展或部分了结,整体视为进行中)
fn aggregate_parent_status(map: &std::collections::HashMap<String, i64>, total: i64) -> String {
let c = |k: &str| map.get(k).copied().unwrap_or(0);
let blocked = c("blocked");
let active = c("in_progress") + c("in_review") + c("testing");
let settled = c("done") + c("cancelled");
let todo = c("todo");
let deferred = c("deferred");
if blocked > 0 {
"blocked".to_string()
} else if active > 0 {
"in_progress".to_string()
} else if settled == total {
"done".to_string()
} else if todo == total {
"todo".to_string()
} else if deferred == total {
"deferred".to_string()
} else {
"in_progress".to_string()
}
}
/// 父任务 status 重算(容器模型,不走状态机)。聚合规则见 [`aggregate_parent_status`]。
///
/// 无子任务(悬空)→ 不重算,返回当前 status。
/// 数据源 `repo.count_children_by_status`(一次 GROUP BY 查询,数据量小无压力);
/// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径)。
/// 状态相同则不写(避免无谓 updated_at 抖动)。
///
/// 返回:重算后的父任务最新 status。
/// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径,
/// 状态相同则不写,避免无谓 updated_at 抖动)。返回重算后的最新 status。
pub async fn recompute_parent_status(
repo: &TaskRepo,
parent_id: &str,
@@ -139,27 +183,7 @@ pub async fn recompute_parent_status(
// 转 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()
};
let new_status = aggregate_parent_status(&map, total);
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
let current = repo
@@ -256,7 +280,7 @@ impl Node for TaskAdvanceNode {
"task_id": { "type": "string" },
"target_status": {
"type": "string",
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled", "deferred"]
}
},
"required": ["task_id", "target_status"]
@@ -288,6 +312,69 @@ mod tests {
use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
// ---------- aggregate_parent_status 纯函数(全状态集覆盖) ----------
/// 由 (status, count) 列表构造 map + total,喂给 aggregate_parent_status。
fn agg(pairs: &[(&str, i64)]) -> String {
let map: std::collections::HashMap<String, i64> =
pairs.iter().map(|(s, n)| (s.to_string(), *n)).collect();
let total: i64 = pairs.iter().map(|(_, n)| *n).sum();
aggregate_parent_status(&map, total)
}
#[test]
fn agg_blocked_wins_over_all() {
// 阻塞优先,不被任何其他态掩盖
assert_eq!(agg(&[("blocked", 1), ("in_progress", 3), ("done", 2)]), "blocked");
assert_eq!(agg(&[("blocked", 1), ("deferred", 5)]), "blocked");
}
#[test]
fn agg_any_active_to_in_progress() {
// 活跃三态任一在 → in_progress
assert_eq!(agg(&[("in_progress", 1), ("todo", 2)]), "in_progress");
assert_eq!(agg(&[("in_review", 1), ("done", 2)]), "in_progress");
assert_eq!(agg(&[("testing", 1), ("deferred", 2)]), "in_progress");
}
#[test]
fn agg_all_settled_to_done() {
assert_eq!(agg(&[("done", 3)]), "done");
assert_eq!(agg(&[("cancelled", 2)]), "done");
assert_eq!(agg(&[("done", 2), ("cancelled", 1)]), "done");
}
#[test]
fn agg_all_todo_to_todo() {
assert_eq!(agg(&[("todo", 4)]), "todo");
}
#[test]
fn agg_all_deferred_to_deferred() {
// P0-1 核心回归:全暂缓不再误报 in_progress
assert_eq!(agg(&[("deferred", 3)]), "deferred");
}
#[test]
fn agg_static_mixed_to_in_progress() {
// P0-1 回归:todo+deferred / deferred+done 等静置收尾混合 → in_progress
assert_eq!(agg(&[("todo", 1), ("deferred", 2)]), "in_progress");
assert_eq!(agg(&[("deferred", 1), ("done", 2)]), "in_progress");
assert_eq!(agg(&[("todo", 1), ("done", 1)]), "in_progress");
}
#[test]
fn agg_result_always_valid_state() {
// 性质:任意状态组合的聚合结果必是合法状态值(防未来加态漏改落非法串)
use crate::task_state_machine::{is_valid_state, ALL_STATES};
for a in ALL_STATES {
for b in ALL_STATES {
let out = agg(&[(a, 1), (b, 2)]);
assert!(is_valid_state(&out), "agg({a},{b}) = {out:?} 非合法状态值");
}
}
}
fn rec(id: &str, status: TaskStatus) -> TaskRecord {
TaskRecord {
id: id.to_string(),
+163 -25
View File
@@ -1,11 +1,11 @@
//! 任务推进状态机 — 7 态合法转换定义。
//! 任务推进状态机 — 8 态合法转换定义。
//!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成)。
//!
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled
//! 8 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled / deferred
//!
//! 闸门链主路径: todo → in_progress → in_review → testing → done
//!
@@ -19,7 +19,7 @@
//! blocked, cancelled
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
//! - blocked → in_progress(解除阻塞继续), cancelled
//! - cancelled → 终态
//! - cancelled → todo(取消后恢复,非终态;仅 done 为终态)
// ============================================================
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
@@ -43,8 +43,10 @@ pub const TESTING: &str = TaskStatus::Testing.as_str();
pub const DONE: &str = TaskStatus::Done.as_str();
/// 已阻塞
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
/// 已取消(终态)
/// 已取消(可恢复为待开始)
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
/// 已暂缓
pub const DEFERRED: &str = TaskStatus::Deferred.as_str();
/// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[
@@ -55,6 +57,7 @@ pub const ALL_STATES: &[&str] = &[
DONE,
BLOCKED,
CANCELLED,
DEFERRED,
];
/// 字符串是否为合法状态值
@@ -64,7 +67,7 @@ pub fn is_valid_state(s: &str) -> bool {
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
///
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
/// done 为唯一终态,无任何合法后继(cancelled 可恢复为 todo,非终态);非法或未知状态入参一律返回 false
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
///
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
@@ -101,7 +104,21 @@ pub fn can_transition(from: &str, to: &str) -> bool {
// blocked → in_progress(解除阻塞继续), cancelled
(BLOCKED, IN_PROGRESS),
(BLOCKED, CANCELLED),
// cancelled → 终态,无后继
// cancelled → todo(取消后恢复)
(CANCELLED, TODO),
// deferred → todo(恢复), cancelled(取消)
(DEFERRED, TODO),
(DEFERRED, CANCELLED),
// todo → deferred(延后)
(TODO, DEFERRED),
// in_progress → deferred
(IN_PROGRESS, DEFERRED),
// in_review → deferred
(IN_REVIEW, DEFERRED),
// testing → deferred
(TESTING, DEFERRED),
// blocked → deferred
(BLOCKED, DEFERRED),
];
for (f, t) in allowed {
m.insert((*f, *t), true);
@@ -117,7 +134,7 @@ pub fn can_transition(from: &str, to: &str) -> bool {
/// 供 advance_task 错误提示复用:状态机拒绝非法转换时,把「当前状态 + 合法目标」附进
/// 错误信息,让 LLM 下次直接选对目标态,避免靠猜反复触发状态机拒绝。
/// 遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐(矩阵改动自动同步)。
/// 终态(done/cancelled)无合法后继 → 返回空列表,调用方据此提示「终态无后继」。
/// done 为唯一终态无合法后继 → 返回空列表,调用方据此提示「终态无后继」。
pub fn legal_targets(from: &str) -> Vec<&'static str> {
ALL_STATES
.iter()
@@ -126,6 +143,60 @@ pub fn legal_targets(from: &str) -> Vec<&'static str> {
.collect()
}
/// 基于 BFS 计算从 `from` 到 `to` 的最短合法转换路径(含首尾)。
///
/// 当 `can_transition(from, to)` 为 false 时,调用方可将此路径附进错误提示,
/// 让 LLM 或用户知道「一步跳不到,但可以这样走」。
///
/// 返回路径列表,第一项为 `from`, 最后一项为 `to`。
/// 如果 `from == to`(同态)或不存在可达路径,返回空切片。
/// 注意:done 是唯一终态,从非终态到 done 只有主路径一条(跳闸门仍不可达);
/// 从 done 到任何态均不可达,返回空。
pub fn shortest_path(from: &str, to: &str) -> Vec<&'static str> {
use std::collections::VecDeque;
if from == to {
return vec![]; // 同态,无路径
}
if !is_valid_state(from) || !is_valid_state(to) {
return vec![]; // 未知态
}
// BFS:队列存(当前节点,路径);visited 跳过已探节点。
let mut visited = std::collections::HashSet::<&'static str>::new();
let mut queue: VecDeque<Vec<&'static str>> = VecDeque::new();
// from 已知是合法状态值,unwrap 安全
let from_static = ALL_STATES.iter().find(|s| **s == from).copied().unwrap();
visited.insert(from_static);
queue.push_back(vec![from_static]);
while let Some(path) = queue.pop_front() {
let current = path.last().copied().unwrap();
for candidate in ALL_STATES {
if !can_transition(current, candidate) {
continue;
}
if *candidate == to {
let mut full = path.clone();
full.push(candidate);
return full;
}
if visited.insert(candidate) {
let mut next = path.clone();
next.push(candidate);
queue.push_back(next);
}
}
}
vec![] // 不可达
}
/// 格式化为人类可读路径字符串,如 "deferred → todo → in_progress → blocked"。
pub fn format_path(path: &[&'static str]) -> String {
path.join("")
}
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
///
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
@@ -217,11 +288,9 @@ mod tests {
#[test]
fn terminal_states_have_no_successors() {
// done / cancelled 是终态,任何后继都拒绝
for term in [DONE, CANCELLED] {
for to in ALL_STATES {
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}");
}
// done 是唯一终态,任何后继都拒绝(cancelled 已支持取消后恢复,非终态)
for to in ALL_STATES {
assert!(!can_transition(DONE, to), "终态 {DONE} 不应有后继 → {to}");
}
}
@@ -248,14 +317,15 @@ mod tests {
#[test]
fn legal_targets_matches_transition_matrix() {
// 逐态锁定合法目标列表(顺序 = ALL_STATES 遍历序),与 can_transition 矩阵一一对应
assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED]);
assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED]);
assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED]);
assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED]);
assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED]);
// 终态无合法后继
assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(CANCELLED), vec![TODO]);
assert_eq!(legal_targets(DEFERRED), vec![TODO, CANCELLED]);
// done 是唯一终态,无合法后继
assert!(legal_targets(DONE).is_empty());
assert!(legal_targets(CANCELLED).is_empty());
}
#[test]
@@ -331,7 +401,7 @@ mod tests {
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
// 该字符串过不了 is_valid_state,本测试立即失败定位。
// 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。
// 覆盖全部 8 态入参,不依赖上例已锁定的具体期望值。
for target in ALL_STATES {
match regression_target(target) {
Some(ret) => assert!(
@@ -346,7 +416,7 @@ mod tests {
// ---------- is_valid_state ----------
#[test]
fn is_valid_state_accepts_7_known() {
fn is_valid_state_accepts_all_known() {
for s in ALL_STATES {
assert!(is_valid_state(s));
}
@@ -362,13 +432,13 @@ mod tests {
}
#[test]
fn all_states_has_seven_entries() {
assert_eq!(ALL_STATES.len(), 7);
fn all_states_has_eight_entries() {
assert_eq!(ALL_STATES.len(), 8);
}
// ---------- 双源一致性校验 ----------
//
// task_state_machine 维护一份独立的 7 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// task_state_machine 维护一份独立的 8 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
@@ -407,5 +477,73 @@ mod tests {
assert_eq!(DONE, TaskStatus::Done.as_str());
assert_eq!(BLOCKED, TaskStatus::Blocked.as_str());
assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str());
assert_eq!(DEFERRED, TaskStatus::Deferred.as_str());
}
// ---------- shortest_path(最短路径推导) ----------
#[test]
fn shortest_path_deferred_to_blocked() {
// deferred→todo→in_progress→blocked:最短 3 跳
let path = shortest_path(DEFERRED, BLOCKED);
assert_eq!(path, vec![DEFERRED, TODO, IN_PROGRESS, BLOCKED]);
}
#[test]
fn shortest_path_direct_transition() {
// 一步可达:todo→in_progress
let path = shortest_path(TODO, IN_PROGRESS);
assert_eq!(path, vec![TODO, IN_PROGRESS]);
}
#[test]
fn shortest_path_same_state_returns_empty() {
// 同态返回空
assert!(shortest_path(TODO, TODO).is_empty());
assert!(shortest_path(IN_PROGRESS, IN_PROGRESS).is_empty());
}
#[test]
fn shortest_path_from_terminal_returns_empty() {
// done 是终态,无任何后继
assert!(shortest_path(DONE, TODO).is_empty());
assert!(shortest_path(DONE, IN_PROGRESS).is_empty());
}
#[test]
fn shortest_path_unknown_state_returns_empty() {
assert!(shortest_path("merged", TODO).is_empty());
assert!(shortest_path(TODO, "merged").is_empty());
assert!(shortest_path("", "").is_empty());
}
#[test]
fn shortest_path_multi_hop_consistency() {
// 多跳路径:第一条边必须是 legal_targets(from) 的子集
let path = shortest_path(DEFERRED, BLOCKED);
assert_eq!(path.len(), 4, "deferred→blocked 最短是 4 节点,实际: {:?}", path);
// 首条边 deferred→todo 必须合法
assert!(can_transition(path[0], path[1]));
// 中间每条边都合法
for i in 0..path.len() - 1 {
assert!(
can_transition(path[i], path[i + 1]),
"路径边 {i}: {}→{} 不合法",
path[i],
path[i + 1]
);
}
// 最后一段必须是 blocked
assert_eq!(path.last().copied().unwrap(), BLOCKED);
}
#[test]
fn format_path_joins_with_arrow() {
assert_eq!(
format_path(&[DEFERRED, TODO, IN_PROGRESS, BLOCKED]),
"deferred → todo → in_progress → blocked"
);
assert_eq!(format_path(&[TODO, IN_PROGRESS]), "todo → in_progress");
assert_eq!(format_path(&[]), "");
}
}
+103 -4
View File
@@ -319,6 +319,38 @@ impl AiToolExecutionRepo {
.map_err(storage_err)?
}
/// 按 (conversation_id, tool_name, arguments) 查最新一条审计记录(重试防护用)。
///
/// 语义:同一会话内同一工具同一参数的重试判重。区别于 `find_by_tool_call_id`
/// (裸 id 匹配,弱模型 provider 的 tool_call_id 每轮重排,裸 id 判重会误杀跨轮合法调用,
/// 实证 2026-08-11)。
pub async fn find_by_conv_tool_args(
&self,
conversation_id: &str,
tool_name: &str,
arguments: &str,
) -> Result<Option<AiToolExecutionRecord>> {
let conn = self.conn.clone();
let cid = conversation_id.to_owned();
let tname = tool_name.to_owned();
let args = arguments.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT * FROM ai_tool_executions WHERE conversation_id = ?1 AND tool_name = ?2 AND arguments = ?3 ORDER BY requested_at DESC LIMIT 1",
)
.map_err(storage_err)?;
let row = stmt
.query_row(params![cid, tname, args], |row| ai_tool_execution_from_row(row))
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 列出所有 status=pending 的审计行(启动重建 pending_approvals 用)
///
/// 专用 SELECT(非 query 宏——后者硬编码 ORDER BY created_at,而本表无该列)。
@@ -428,9 +460,9 @@ impl AiToolExecutionRepo {
params_vec.push(Box::new(r.clone()));
}
if let Some(k) = &kw {
let escaped = k.replace('%', "\\%").replace('_', "\\_");
let escaped = k.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%");
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1));
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '|'", params_vec.len() + 1));
params_vec.push(Box::new(pat));
}
@@ -491,9 +523,9 @@ impl AiToolExecutionRepo {
params_vec.push(Box::new(r.clone()));
}
if let Some(k) = &kw {
let escaped = k.replace('%', "\\%").replace('_', "\\_");
let escaped = k.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%");
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1));
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '|'", params_vec.len() + 1));
params_vec.push(Box::new(pat));
}
@@ -844,4 +876,71 @@ mod tests {
assert_eq!(legacy.model_configs.len(), 2, "老字符串数组应转 2 个默认 ModelConfig");
assert_eq!(legacy.model_configs[0].model_id, "glm-4-flash");
}
// ---------- find_by_conv_tool_args(重试判重,SC-260811-P0-1) ----------
/// 构造一条审计记录(参数自洽,满足 NOT NULL 约束)。
fn audit_rec(
id: &str, conv: &str, tc_id: &str, tool: &str, args: &str, status: &str,
) -> AiToolExecutionRecord {
AiToolExecutionRecord {
id: id.into(),
conversation_id: Some(conv.into()),
message_id: None,
tool_call_id: tc_id.into(),
tool_name: tool.into(),
arguments: args.into(),
result: None,
status: status.into(),
risk_level: "low".into(),
requested_at: "0".into(),
executed_at: None,
decided_by: None,
}
}
/// P0-1 核心语义:弱模型跨轮复用同 tool_call_id(gen_stream_0),但工具/参数不同 →
/// find_by_conv_tool_args 不得误判为重试(旧 find_by_tool_call_id 会误命中)。
#[tokio::test]
async fn find_by_conv_tool_args_ignores_same_id_different_tool() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiToolExecutionRepo::new(&db);
// 会话开头已执行过 list_tasks(gen_stream_0),之后同会话又出现 gen_stream_0 的 advance_task
repo.insert(audit_rec("a1", "conv1", "gen_stream_0", "list_tasks", "{\"limit\":50}", "completed")).await.expect("insert");
repo.insert(audit_rec("a2", "conv1", "gen_stream_0", "advance_task", "{\"id\":\"t1\",\"target_status\":\"blocked\"}", "completed")).await.expect("insert");
// 查 advance_task 同三元组 → 命中(a2)
let got = repo.find_by_conv_tool_args("conv1", "advance_task", "{\"id\":\"t1\",\"target_status\":\"blocked\"}").await.expect("query");
assert!(got.is_some(), "同 (conv,tool,args) 应命中");
assert_eq!(got.unwrap().id, "a2");
// 查 list_tasks 同三元组 → 命中(a1),互不串扰
let got2 = repo.find_by_conv_tool_args("conv1", "list_tasks", "{\"limit\":50}").await.expect("query");
assert!(got2.is_some());
assert_eq!(got2.unwrap().id, "a1");
// 跨会话同 tool_call_id 不命中(不同 conv)
let got3 = repo.find_by_conv_tool_args("conv2", "advance_task", "{\"id\":\"t1\",\"target_status\":\"blocked\"}").await.expect("query");
assert!(got3.is_none(), "不同会话同参数不应命中");
}
/// 同 (conv,tool,args) 且已落定(completed) → 判重返回记录(调用方 retry_count≥1);
/// pending 视为"尚未落定"(首次挂起审批)不判重。
#[tokio::test]
async fn find_by_conv_tool_args_pending_not_retry() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiToolExecutionRepo::new(&db);
repo.insert(audit_rec("a1", "conv1", "call_x", "write_file", "{\"path\":\"a.go\"}", "pending")).await.expect("insert");
// pending 记录不视为重试
let got = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query");
assert!(got.is_some(), "pending 记录也应能查到(状态判定在调用方 detect_retry_count 做,此处只负责按三元组定位)");
assert_eq!(got.unwrap().status, "pending");
// 同三元组 latest(有 completed 应返回最新一条 requested_at DESC)
let mut rec2 = audit_rec("a2", "conv1", "call_x", "write_file", "{\"path\":\"a.go\"}", "completed");
rec2.requested_at = "1".into(); // 晚于 a1 的 "0",保证 DESC 序可辨
repo.insert(rec2).await.expect("insert");
let got2 = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query");
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
}
}
+6 -5
View File
@@ -301,11 +301,12 @@ impl IdeaRepo {
params_vec.push(Box::new(s.clone()));
}
if let Some(kw) = &keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
// ESCAPE 跟单个 LIKE(不能跟括号分组,否则 near "ESCAPE" syntax error)
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' OR description LIKE ?{p2} ESCAPE '|')"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
@@ -638,7 +639,7 @@ impl KnowledgeRepo {
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let escaped = query.replace('%', "\\%").replace('_', "\\_");
let escaped = query.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pattern = format!("%{escaped}%");
let kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64;
@@ -647,7 +648,7 @@ impl KnowledgeRepo {
let mut results = Vec::new();
if let Some(k) = &kind {
let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '|' OR content LIKE ?2 ESCAPE '|') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -657,7 +658,7 @@ impl KnowledgeRepo {
}
} else {
let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '|' OR content LIKE ?2 ESCAPE '|') ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -215,19 +215,30 @@ use rusqlite::OptionalExtension;
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::project_repo::ProjectRepo;
use crate::db::Database;
use crate::models::{ProjectRecord, ProjectModuleRecord};
use df_types::types::ProjectStatus;
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
async fn setup() -> (Database, ProjectModuleRepo, String) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_id = "proj-test".to_string();
db.conn()
.blocking_lock()
.execute(
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')",
params![project_id, "Test Project"],
)
// 用异步 repo API 建占位 project(勿在 async 上下文直接 blocking_lock,会触发
// "Cannot block the current thread from within a runtime" panic,实证 2026-08-11)。
ProjectRepo::new(&db)
.insert(ProjectRecord {
id: project_id.clone(),
name: "Test Project".to_string(),
description: String::new(),
status: ProjectStatus::InProgress,
idea_id: None,
path: Some("/tmp".into()),
stack: Some("rust".into()),
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert placeholder project");
let repo = ProjectModuleRepo::new(&db);
(db, repo, project_id)
+3 -2
View File
@@ -315,8 +315,9 @@ impl ProjectRepo {
if let Some(kw) = &q.keyword {
let trimmed = kw.trim();
if !trimmed.is_empty() {
let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
sql.push_str(" AND (name LIKE ? OR description LIKE ?) ESCAPE '\\'");
let escaped = trimmed.replace('|', "||").replace('%', "|%").replace('_', "|_");
// ESCAPE 跟单个 LIKE(不能跟括号分组,否则 near "ESCAPE" syntax error)
sql.push_str(" AND (name LIKE ? ESCAPE '|' OR description LIKE ? ESCAPE '|')");
let pattern = format!("%{escaped}%");
params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern));
+50 -4
View File
@@ -391,12 +391,17 @@ impl TaskRepo {
params_vec.push(Box::new(mid.clone()));
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
// ESCAPE 字符用 |(管道符,任务标题/描述几乎不含),不用反斜杠——反斜杠在
// Rust format! → rusqlite 绑定 → SQLite 多层转义里极易出错(SQLite 报
// "ESCAPE expression must be a single character"),改 | 一劳永逸。
// 注意:ESCAPE 只能跟单个 LIKE,不能跟括号分组(实测 `(a OR b) ESCAPE 'x'`
// 报 near "ESCAPE" syntax error),故每个 LIKE 各自 ESCAPE。
if let Some(kw) = &keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' OR description LIKE ?{p2} ESCAPE '|')"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
@@ -494,11 +499,11 @@ impl TaskRepo {
params_vec.push(Box::new(a.clone()));
}
if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' OR description LIKE ?{p2} ESCAPE '|')"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
@@ -1358,4 +1363,45 @@ mod tests {
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "新标题", "软删后字段不应被改动");
}
// ============================================================
// keyword LIKE 查询(ESCAPE 转义)—— 防 2026-08-11 语法回归
// ============================================================
// 背景:旧写法 `(a LIKE ?1 OR b LIKE ?2) ESCAPE '|'`(ESCAPE 跟括号分组)在 SQLite
// 报 near "ESCAPE" syntax error,keyword 查询全挂。正确写法:ESCAPE 跟每个 LIKE。
// 本测试锁两种语义:普通子串匹配 + 含 %/_ 通配符字面匹配(转义生效)。
#[tokio::test]
async fn list_by_query_keyword_matches_substring() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.insert(trec("t2", "todo", None)).await.unwrap();
// 定制 title:t1 含「支付」,t2 不含
repo.update_field_active("t1", "title", "海外支付集成").await.unwrap();
let rows = repo
.list_by_query(&TaskQuery { keyword: Some("支付".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(rows.len(), 1, "keyword 子串应只命中 t1");
assert_eq!(rows[0].id, "t1");
}
#[tokio::test]
async fn list_by_query_keyword_escapes_wildcards() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.insert(trec("t2", "todo", None)).await.unwrap();
// t1 标题含字面 % 与 _(通配符需转义,按字面匹配)
repo.update_field_active("t1", "title", "比率 100%_cache").await.unwrap();
repo.update_field_active("t2", "title", "比率 100x_cache").await.unwrap();
// 查询字面 "%_"(含两个通配符,转义后应按字面匹配 t1;t2 的 x 不匹配 %)
let rows = repo
.list_by_query(&TaskQuery { keyword: Some("100%_".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(rows.len(), 1, "% 与 _ 应被转义为字面,只命中 t1");
assert_eq!(rows[0].id, "t1");
}
}
+9 -2
View File
@@ -353,6 +353,8 @@ pub enum TaskStatus {
Blocked,
/// 已取消
Cancelled,
/// 已暂缓
Deferred,
}
impl TaskStatus {
@@ -369,6 +371,7 @@ impl TaskStatus {
TaskStatus::Done => "done",
TaskStatus::Blocked => "blocked",
TaskStatus::Cancelled => "cancelled",
TaskStatus::Deferred => "deferred",
}
}
@@ -378,7 +381,7 @@ impl TaskStatus {
pub fn is_valid(s: &str) -> bool {
matches!(
s,
"todo" | "in_progress" | "in_review" | "testing" | "done" | "blocked" | "cancelled"
"todo" | "in_progress" | "in_review" | "testing" | "done" | "blocked" | "cancelled" | "deferred"
)
}
@@ -392,6 +395,7 @@ impl TaskStatus {
"done",
"blocked",
"cancelled",
"deferred",
]
}
@@ -408,6 +412,7 @@ impl TaskStatus {
"done" => TaskStatus::Done,
"blocked" => TaskStatus::Blocked,
"cancelled" => TaskStatus::Cancelled,
"deferred" => TaskStatus::Deferred,
_ => return None,
})
}
@@ -598,6 +603,7 @@ mod tests {
assert!(TaskStatus::is_valid("done"));
assert!(TaskStatus::is_valid("blocked"));
assert!(TaskStatus::is_valid("cancelled"));
assert!(TaskStatus::is_valid("deferred"));
}
// as_str 与 is_valid 必须自洽:每个变体的存储值都应被 is_valid 接受
@@ -611,6 +617,7 @@ mod tests {
TaskStatus::Done,
TaskStatus::Blocked,
TaskStatus::Cancelled,
TaskStatus::Deferred,
];
for v in all {
assert!(
@@ -673,7 +680,7 @@ mod tests {
assert!(TaskStatus::is_valid(v), "valid_values 含 {:?} 但 is_valid 拒绝", v);
}
// 数量应等于枚举变体数
assert_eq!(TaskStatus::valid_values().len(), 7);
assert_eq!(TaskStatus::valid_values().len(), 8);
}
// ── Priority::from_i32(对齐前端约定 0=Critical/1=High/2=Medium/3=Low)──
+22 -18
View File
@@ -36,15 +36,15 @@
## 🎯 统一规划(2026-06-26 全景)
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅落地待实测) + 多工程方案。
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅落地) + 多工程方案。
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
### 主线脉络
```
G1目标钉扎(✅落地,待实测) → 知识图谱Phase1(任务网络,AI编排地基)
G1目标钉扎(✅落地) → 知识图谱Phase1(任务网络,AI编排地基)
↓ ↓
实测验证 Phase4注入(复用G1的system_prompt注入基建)
实测验证
↓ ↑
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
```
@@ -61,7 +61,7 @@ graph TD
classDef todo fill:#eef,stroke:#99c
classDef blocked fill:#fcc,stroke:#c33
G1["G1 目标钉扎<br/>✅代码 8ce18cb / 🔨待实测"]:::doing
G1["G1 目标钉扎<br/>✅代码 8ce18cb"]:::done
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
P3["父③ AI对话体验<br/>📋待办"]:::todo
@@ -79,7 +79,7 @@ graph TD
| 任务 | 状态 | 子项 | 依赖(前置) |
|---|---|---|---|
| **G1** 目标钉扎 | 🔨 代码`8ce18cb` 待实测 | G1/G2/G4 落地 | — |
| **G1** 目标钉扎 | 代码已落地 | G1/G2/G4 落地 | — |
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
| **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 / 对话透明化 L1🟡待定 | — |
@@ -123,7 +123,7 @@ graph TD
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成 + 用户实测后可清。
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。
---
@@ -141,11 +141,10 @@ graph TD
|---|---|---|
| **A todo 卫生** | 矛盾清理/被取代退役标注 | ✅ 本批已完成 |
| **B 零风险减法** | 死链清/空壳合并 | ✅ 本批已完成 |
| **C 需用户输入** | 阻塞,无法代办 | S-260615-01 curl 测 / S-260614-01 多开澄清 / S-260614-02 实测重评 |
| **C 需用户输入** | 阻塞,无法代办 | —(已归档)
| **D 功能增强** | 设计清晰可推进(P1) | F-15-01 审批选项 / F-15-02 task 详情 / F-15-03 分页⚠️breaking / F-15-04 卡片折叠 |
| **E 架构重投入** | 需设计,非小改 | F-14-01 模型能力 / F-14-07 trait 下沉 / ARC-05·06 store 拆·循环依赖 |
| **F 全局 review P2 需设计** | 安全/竞态收口 | R-PD-4/5/6/8/9/12/13 + R-PD-10 .map_err 85 处 |
| **G 测试/dev 验证** | 收尾土壤 | ARC-08 dev 验证 / B-03b-R8 human 端到端 / T-14-01·02 Sprint 实测 |
| **H 长期功能池** | 不进主线 | F-14-02~10 / T-14-06 Settings 拆 / T-14-11 条件引擎 / B-14-05 / B-03b-R9 |
**推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。
@@ -199,8 +198,6 @@ graph TD
### P1 — 重要缺陷
- [ ] T-260614-01 — **[P1]** Sprint 9/10/14/15/16/18 多项未 tauri dev 实测 — 评分 IPC 缩放 / update_full / promote_idea / Store getter / token 落库 / 知识库 Tier 1 全栈 / LLM 并发 Semaphore / 知识生命线(#54 跟踪)— source:Sprint 9-18 (06-14)
- [ ] T-260614-02 — **[P1]** 切对话不中断路由:部分场景运行时实测(A 路线场景 2/3 — source:Sprint 8 (06-14)
### 🔴 架构坏味道全面扫描(2026-06-18·6路并行 Explore agent·57K行代码)
@@ -224,8 +221,7 @@ graph TD
### 待澄清 / A-B 待定
- [ ] S-260614-01 — **[用户已澄清 2026-06-17]** 「显示多开」= 多个会话来回切都可对话(非 AI Chat 窗口多开)。关联 T-260614-02(切对话不中断实测)+ F-09 多会话架构。**A 路线(单例软隔离,已做)**:切会话软复位+DB 加载上下文+active 切换,来回切应能对话(需实测 T-260614-02)。**B 路线(真多会话,未做)**:切走会话 generating 不打断,每会话独立 AiSession(memory aichat-arch-extensibility 单例未动)。**✅ 已并入 F-260616-09(2026-06-16 已决 a):A 路线已实施待实测(T-260614-02),B 高优先立项,详见待决策归档/2026-06.md**
- 🟡 **S-260614-02 审批可见性** — 待用户实测 tc 卡片是否渲染(需你跑一下)
- [ ] S-260614-01 — **[用户已澄清 2026-06-17]** 「显示多开」= 多个会话来回切都可对话(非 AI Chat 窗口多开)。**✅ 已并入 F-260616-09(已决 a):A 路线已实施,B 高优先立项,详见待决策归档/2026-06.md**
#### ℹ️ 架构观察(INFO · 2 项·不进修复队列)
@@ -291,7 +287,6 @@ graph TD
- **P4** 双向同步完善(冲突处理 + 安全 + 离线降级 + 部署)— 依赖 P1/P2/P3
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
- [ ] F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵(6 场景含 F-09 跨端并发)
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
@@ -447,9 +442,6 @@ graph TD
**P3(外部依赖/低优·2 项)**:
- [ ] **MR-P3-1** — miniapp `manifest.json` appid 占位符:需微信公众平台注册真实 appid 才能真机预览/发布(P3-B-260622 遗留,外部依赖)
- [ ] **MR-P3-2** — 真机联调:F-260622-01 阶段4 真机联调 + 多会话并发验证矩阵(含 F-09 跨端并发),待 appid 就绪
---
### 🔍 2026-08-07 aichat 切换/新建对话缺陷走查(用户实测 2 bug + 双 agent 深挖)
@@ -476,8 +468,9 @@ graph TD
- [x] **CSW-P2-3** — ✅ 已修:`conversation.rs:211-226` save_conversation_serialized 持 per-conv 锁(`mod.rs:696-701` save_locks)串行化 DELETE+INSERT,20+ 调用点无旁路
- [x] **CSW-P2-4** — ✅ 已修:`state.rs:561-565` clear_session_allowed_dirs_if_idle 仅在 `active_convs().is_empty()` 才清,后台 loop 文件工具不丢临时授权(三调用点 create/switch/delete 均用 _if_idle)
- [x] **CSW-P2-5** — ✅ 已修:`useAiConversations.ts:67/75-76/110` `_newConvLock` 防抖 + 300ms 释放
- [x] **CSW-P2-6** — ✅ 已修:`streamingGuard.ts:78` 带 convId 写 per-conv 流式态,无 convId 才写全局
- [ ] **CSW-P2-7** — 🟡 **待确认(未核对)**:切生成中会话快照陈旧 + 恢复双占位气泡;d2 分离窗口视图不随主窗口失效;跨端生成中会话不流式(走查自标待确认,需真机/分离窗口实测再判)
- [ ] **CSW-P2-6** — ✅ 已修:`streamingGuard.ts:78` 带 convId 写 per-conv 流式态,无 convId 才写全局
**其他已销项**:CSW-P2-7(需实测确认,从待办去除)
---
### 🔍 2026-08-09 TaskDetail 操作按钮缺陷走查(用户实测 + AI 深度审查)
@@ -777,4 +770,15 @@ graph TD
---
**🟡 P1**:
- [ ] **SC-260811-P1-1** — 状态机最短路径推导:deferred→blocked 需 4 跳,AI 靠试错 47 次 advance_task。**建议**:错误信息附最短合法路径,或提供一步跳转(内部串多跳)
**🔧 既有测试失败修复(2026-08-11)**:累计修 7 项既有失败,df-nodes 175 全过 / df-storage 166 全过:
- ✅ `ai_self_review_node::clamp_dimension_scores_in_place` 类型保真:区间内整数保持整数(9 不转 9.0),仅越界 clamp——修 parse_review_json_valid_passes_through,同步调 clamps_out_of_range_scores 边界值断言(10 保持整数)
- ✅ `build_review_prompt` 模板说明文字裸 `<task_requirements>` 与真实标签同名,测试 find 首现误命中行内引用→真实标签加换行锚定定位(`\n<tag>\n`),说明文字标签加反引号
- ✅ `project_module_repo::setup` async 上下文直接 `blocking_lock()` 触发 "Cannot block the current thread" panic→改用 `ProjectRepo::insert` 异步 API(对齐 service_repo 风格)
---
**关联**:本走查与「2026-08-09 TaskDetail 操作按钮缺陷走查」同属 TaskDetail 交互审查线,但本段聚焦 AI 工作流推进链路(后端 + 审批 UI),按钮缺陷聚焦 TaskDetail 自身交互。修复时建议合并处理(都在 TaskDetail + workflow 链路)。
+15
View File
@@ -21,6 +21,7 @@
"html2canvas": "^1.4.1",
"marked": "^18.0.5",
"mermaid": "^11.16.0",
"sortablejs": "^1.15.7",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
@@ -29,6 +30,7 @@
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/dompurify": "^3.2.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.7",
@@ -1634,6 +1636,13 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -3124,6 +3133,12 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+2
View File
@@ -26,6 +26,7 @@
"html2canvas": "^1.4.1",
"marked": "^18.0.5",
"mermaid": "^11.16.0",
"sortablejs": "^1.15.7",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
@@ -34,6 +35,7 @@
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/dompurify": "^3.2.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.7",
+136 -2
View File
@@ -224,6 +224,20 @@ pub const STALL_BREAKER_THRESHOLD: u32 = 3;
/// 确认无误杀场景用。
pub const STALL_BREAKER_WARN_FIRST: bool = true;
/// 目标验证续轮开关(默认 false,灰度放开)。
///
/// 治"过早宣称":末轮无 tool_calls 时,若 pinned_goals 非空且无 applied.matched_target
/// 证据 → 不收敛,注入 nudge 消息强制续轮验证。机制非 prompt 说教(实证无效)。
/// false(默认,回退):整块跳过,converged 判定逐字节等价改动前(只看末轮有无 tool_calls)。
/// 主入口保障:默认关,自测+历史会话回放验证不误伤后再默认开。
pub const GOAL_VERIFY_ENABLED: bool = false;
/// 目标验证续轮上限(默认 2)。
///
/// 每强制续轮 +1,达上限无条件强制收敛 + AiCompleted 标注 goal_unverified。
/// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。
pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2;
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
///
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
@@ -1353,6 +1367,11 @@ pub(crate) async fn run_agentic_loop(
let mut stall_count: u32 = 0;
let mut stall_warned: bool = false;
// 目标验证续轮计数(GOAL_VERIFY_ENABLED 门控):末轮无 tool_calls 但 pinned_goals 未达成
// 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛
// + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。
let mut goal_verify_rounds: u32 = 0;
// DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
@@ -1969,11 +1988,47 @@ pub(crate) async fn run_agentic_loop(
return;
}
// 无工具调用 → 最终文本响应正常收敛退出
// 无工具调用 → 最终文本响应正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。
#[allow(unused_assignments)]
if !has_tool_calls { converged = true; break; }
if !has_tool_calls {
// 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限
// → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。
// 任一条件不满足 → 正常收敛(逐字节等价改动前)。
let needs_verify = GOAL_VERIFY_ENABLED
&& !pinned_goals_snapshot.is_empty()
&& goal_verify_rounds < GOAL_VERIFY_MAX_ROUNDS
&& !last_round_has_match_evidence(&session_arc, &conv_id).await;
if needs_verify {
goal_verify_rounds += 1;
insert_goal_verify_nudge(&session_arc, &conv_id, iteration, goal_verify_rounds).await;
// 不 break,续轮。下轮 LLM 见 nudge → 调工具验证(计数重置)或诚实收敛。
// 续轮走 loop 顶部正常流程(build_for_request + stream),无特殊路径。
} else {
// 达上限强制收敛(GOAL_VERIFY_ENABLED && goal_verify_rounds >= MAX):
// LLM 经 N 轮 nudge 仍空手停,目标未验证。标注 warn 供事后核查主入口行为。
// (未达上限/开关关/无目标/有证据 → 正常收敛,无额外日志。)
if GOAL_VERIFY_ENABLED
&& !pinned_goals_snapshot.is_empty()
&& goal_verify_rounds >= GOAL_VERIFY_MAX_ROUNDS
{
tracing::warn!(
conv_id = %conv_id, iteration, goal_verify_rounds,
"[GOAL-VERIFY] 达续轮上限强制收敛,目标未验证(LLM 经 nudge 仍空手停)"
);
}
converged = true;
break;
}
}
let _ = converged;
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
// 说明 LLM 对 nudge 作出了工具响应,清零给后续验证留额度。
if GOAL_VERIFY_ENABLED && goal_verify_rounds > 0 && has_tool_calls {
goal_verify_rounds = 0;
}
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
let round_tool_call_count = tool_calls_acc.len();
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
@@ -2282,6 +2337,85 @@ fn fnv1a_32(bytes: &[u8]) -> u32 {
hash
}
// ============================================================
// 目标验证续轮(GOAL_VERIFY_ENABLED 门控)——治"过早宣称"的机制化兜底
// ============================================================
//
// 根因:loop `converged` 判定只看末轮有无 tool_calls(形态),不看目标是否达成(事实)。
// LLM 调完 advance_task 就停 → 被判 converged → 过早宣称。本机制在收敛闸门接事实维度:
// 末轮无 tool_calls 时,若 pinned_goals 非空且本轮无 applied.matched_target 证据 → 不收敛,
// 注入 nudge 强制续轮。复用 G2 insert_stall_warning 范式 + count_recent_failures 读尾消息模式。
//
// 主入口保障:三态兜底(applied 字段缺失/解析异常 → 视为无证据,不误伤也不阻断)、
// 上限熔断(goal_verify_rounds 达 MAX 强制收敛)、调工具重置计数(LLM 在行动非空转)。
/// 检查本轮(末尾连续 Tool 消息)是否有 applied.matched_target=true 的验证证据。
///
/// 读尾部 BREAKER_TAIL_N 条消息,取末尾连续 Tool 消息,解析 content JSON 的
/// `applied.matched_target` 字段。任一为 true 即返 true(本轮有达成证据)。
/// 三态兜底:字段缺失/非 JSON/解析异常 → 返 false(无证据),不阻断也不误判。
async fn last_round_has_match_evidence(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
) -> bool {
let messages = {
let session = session_arc.lock().await;
match session.conv_read(conv_id) {
Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
None => Vec::new(),
}
};
for m in messages.iter().rev() {
if !matches!(m.role, MessageRole::Tool) {
break;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(&m.content) else {
continue;
};
if v.get("applied")
.and_then(|a| a.get("matched_target"))
.and_then(|x| x.as_bool())
.unwrap_or(false)
{
return true;
}
}
false
}
/// 注入目标验证续轮 nudge(强制续轮,非软提示)。
///
/// 文案随 goal_verify_rounds 递进:第 1 次请验证,第 2 次(将达上限)明确最后机会。
/// 复用 insert_stall_warning 的 insert_at(0, system) 范式 + fetch_goal_summary 拼目标。
async fn insert_goal_verify_nudge(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
verify_rounds: u32,
) {
let goals = fetch_goal_summary(session_arc, conv_id).await;
let nudge_text = if verify_rounds + 1 >= GOAL_VERIFY_MAX_ROUNDS {
format!(
"⚠ 这是最后一次验证机会。目标{}仍未给出完成证据。要么立即用工具验证目标已达成,要么诚实说明哪些未完成、为何未完成,不得再无证据宣称完成。",
goals
)
} else {
format!(
"⚠ 你停下了工具调用,但目标{}尚未给出完成证据。在宣称完成前,必须用工具(读取/核对/查询)验证结果与目标一致;若实际未完成,诚实说明现状,不得宣称完成。",
goals
)
};
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"
);
}
}
// ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ──
// 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含
// error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容
+23 -10
View File
@@ -163,7 +163,7 @@ pub(super) async fn insert_pending_approval(
// High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
let retry_count = detect_retry_count(audit_repo, &draft.id).await;
let retry_count = detect_retry_count(audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
@@ -219,28 +219,41 @@ pub(super) async fn insert_pending_approval(
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
///
/// 判重语义:同会话内同工具同参数的重试。区别于旧实现按裸 tool_call_id 判重——
/// 弱模型 provider(如 sensenova)的 tool_call_id 每轮从 0 重排(gen_stream_0/1/2...),
/// 跨轮不同会话的合法调用会复用同一 id,裸 id 判重会把合法新操作误判为「同 id 死循环重试」
/// 静默跳过(实证 2026-08-11:推进任务被跳过 4 次,AI 却宣称成功)。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
/// - 0:审计表无该 (conv, tool, args) 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 (conv, tool, args) 的落定记录(executed/failed/rejected/skipped_retry),
/// 即该调用此前已被审批执行过一次,LLM 又重试同参数 → 调用方据 ≥1 跳过执行 + emit Completed
/// 断「超时/权限错→LLM 死循环重试→重新挂起→用户被迫二次授权」循环。
///
/// 实现:查 `find_by_tool_call_id`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 实现:查 `find_by_conv_tool_args`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
///
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
pub(super) async fn detect_retry_count(
audit_repo: &AiToolExecutionRepo,
conv_id: &str,
tool_name: &str,
args: &str,
) -> u32 {
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
let rec = match audit_repo.find_by_conv_tool_args(conv_id, tool_name, args).await {
Ok(opt) => match opt {
Some(r) => r,
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
tracing::warn!(
"[approval-retry] 查审计表 (conv={}, tool={}) 失败(降级无重试防护): {}",
conv_id, tool_name, e
);
return 0;
}
};
+1 -1
View File
@@ -376,7 +376,7 @@ pub(crate) async fn process_tool_calls(
.unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
-1
View File
@@ -126,7 +126,6 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- \n\
- 使\n\
- \n\
- **** grep//\n\
- **** read_file/search_files\n\
## \n\
- \n\
+79 -2
View File
@@ -495,7 +495,7 @@ pub(crate) async fn stream_llm(
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
retryable: classify_status_with_body(&status_or_class, &raw),
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
@@ -631,6 +631,34 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|| lower.contains("broken pipe")
}
/// 429 额度/配额耗尽特征检测:区分「额度耗尽型 429」(确定性,重试必失败)与
/// 「普通瞬时限流 429」(可重试)。实证:anthropic/GLM 周月额度耗尽返回
/// `[1310] 您已达到每周/每月使用上限,限额将在 X 重置`(code 1310)。
/// 特征命中 → Fatal 不重试;未命中 → 交 is_status_retryable 正常判定。
/// 入参 combined:状态码串 + 错误体原文(如 "HTTP 429 [1310] 已达到上限...")。
fn is_quota_exhaustion(combined: &str) -> bool {
let lower = combined.to_lowercase();
let quota_markers = [
"额度", "限额", "达到上限", "使用上限", "重置",
"quota", "limit exceeded", "usage limit", "quota exhausted",
"insufficient_quota",
// 注意:不含 rate_limit_error——它是标准 429 错误类型(普通瞬时限流也带),
// 误配会把可恢复的限流当额度耗尽降 Fatal。额度语义用上面明确词。
];
// 仅当文本同时含「429」与额度特征,才判定额度耗尽(避免误伤普通限流)。
lower.contains("429")
&& quota_markers.iter().any(|m| lower.contains(m))
}
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
/// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。
fn classify_status_with_body(status_or_class: &str, body: &str) -> bool {
if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
return false;
}
classify_status_or_class(status_or_class)
}
/// 据 provider 流式 error 帧的 message 文本分类是否可重试(A2-B12 / G4.2)。
///
/// openai/anthropic helper 仅将 error 帧的 `message` 字段透传进 chunk.error
@@ -641,8 +669,10 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
/// - 其余 → retryable=true(保守,防误判瞬态为 Fatal)
fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool {
// 已有状态码/传输类 → 走既有单一分类源(显式 4xx Fatal,5xx/429/timeout/connect 可重试)
// 额度耗尽型 429(如 [1310] 已达周/月上限)确定性失败,经 body 感知识别降 Fatal,
// 不再被普通 429 路径误判可重试空耗。
if status_or_class != "unknown" {
return classify_status_or_class(status_or_class);
return classify_status_with_body(status_or_class, msg);
}
// 无状态码:按错误类型关键词明确非重试签名降级 Fatal
let lower = msg.to_lowercase();
@@ -779,6 +809,53 @@ mod tests {
assert!(classify_error_frame_retryable("stream error", &s2));
}
// ---- is_quota_exhaustion / classify_status_with_body:额度耗尽型 429 不重试 ----
/// 额度耗尽型 429anthropic/GLM 周月上限,含 [1310] 与「使用上限/重置」特征)→ 判定额度耗尽
#[test]
fn quota_exhaustion_429_detected() {
let combined = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
assert!(is_quota_exhaustion(combined));
assert!(!classify_status_with_body("HTTP 429", combined));
}
/// 普通瞬时限流 429(无额度特征)→ 不判定额度耗尽,仍可重试
#[test]
fn transient_429_not_quota() {
let combined = "HTTP 429 请求过于频繁,请稍后重试";
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// 额度关键词但无 429 状态码 → 不判定额度耗尽(防误伤普通 5xx)
#[test]
fn quota_marker_without_429_not_detected() {
assert!(!is_quota_exhaustion("HTTP 500 达到上限"));
}
/// classify_status_with_body 非 429 路径行为不变(5xx 仍可重试)
#[test]
fn classify_status_with_body_5xx_retryable() {
assert!(classify_status_with_body("HTTP 500", "Internal Server Error"));
}
/// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤)
#[test]
fn standard_rate_limit_error_not_quota() {
let combined = r#"HTTP 429 {"type":"rate_limit_error","message":"请求过于频繁,请稍后重试"}"#;
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// error 帧 message 含额度耗尽型 429 → 经 classify_error_frame_retryable 降 Fatal 不重试
#[test]
fn error_frame_quota_429_fatal() {
let msg = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
let (s, _) = extract_error_diag_from_str(msg);
assert_eq!(s, "HTTP 429");
assert!(!classify_error_frame_retryable(msg, &s));
}
// ---- extract_error_diag:业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
+25 -7
View File
@@ -38,7 +38,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"list_tasks",
"列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
"列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]),
args => {
@@ -48,7 +48,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} else {
repo.list_all().await?
};
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled/deferred
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
tasks.retain(|t| t.status.as_str() == status);
}
@@ -127,7 +127,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"update_task",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
@@ -139,7 +139,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
if field == "status" {
anyhow::bail!(
"status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
"status 改动须走 advance_task 工具(状态机推进,8 态不可跳跃),本工具不接受 status 字段"
);
}
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
@@ -166,7 +166,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"advance_task",
"推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
"推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
args => {
@@ -177,11 +177,29 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 与 commands::task::advance_task IPC 同源,避免双轨。设计 D3:子任务推进后
// 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。
let repo = df_storage::crud::TaskRepo::new(&db);
// 推进前读 from(供 applied 断言对比,推进链内部也读,多一次轻量读可接受)。
// 读失败不阻断推进 — from 缺失时 applied.from 留 null(向后兼容)。
let from_status = repo.get_by_id(id).await
.ok().flatten().map(|t| t.status.as_str().to_string());
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&repo, id, target_status,
).await?;
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
Ok(serde_json::to_value(&updated)?)
// 返回推进后的 TaskRecord + applied 结构化断言。matched_target = 实际 to 是否
// 等于调用方传的 target(CAS 冲突后状态可能≠预期,matched_target=false 暴露真相)。
// loop 收敛判定(阶段1-2)据此判目标达成,而非只看"LLM 停止调工具"。
let to_status = updated.status.as_str();
let matched = to_status == target_status;
// TaskRecord 字段平铺在顶层(前端 formatAdvanceTask 直接读 r.title/r.status 等,
// 零破坏);applied 作为附加嵌套字段供 loop 收敛判定读取(阶段1-2)。
let mut value = serde_json::to_value(&updated)?;
if let Some(obj) = value.as_object_mut() {
obj.insert("applied".to_string(), serde_json::json!({
"from": from_status,
"to": to_status,
"matched_target": matched,
}));
}
Ok(value)
}
);
+19 -33
View File
@@ -14,7 +14,7 @@ use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
// 事件流埋点辅助(best-effort)
// ============================================================
/// 追加一条项目事件到 project_events(best-effort)。
@@ -75,17 +75,17 @@ pub struct CreateTaskInput {
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub module_id: Option<String>,
/// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。
/// 管理维度池。默认 "todo"(待办池)。
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID(知识图谱 Phase 1 V29)。默认 None = 叶子任务。
/// 父任务 ID。默认 None = 叶子任务。
/// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)
/// 结构化需求规格 JSON 字符串。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// None = 无结构化规格(纯文本 description)。
#[serde(default)]
@@ -102,7 +102,7 @@ fn default_queue() -> String {
}
// ============================================================
// 知识图谱 Phase 1:queue 白名单 + queue/status 一致性约束(IPC 层校验,对标设计 §2.1)
// queue 白名单 + queue/status 一致性约束(IPC 层校验)
// ============================================================
/// queue 合法值白名单(对标设计 §2.1 queue 字段语义)。
@@ -235,7 +235,7 @@ pub async fn get_task_by_id(
/// 创建任务,返回完整记录
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1):扩展 queue/parent_id/content_json 可选参数(向后兼容,
/// 扩展 queue/parent_id/content_json 可选参数(向后兼容,
/// 旧调用方不传等价改造前行为)。三个新参数的 IPC 层校验:
/// - `queue`:白名单校验(validate_queue)+ queue/status 一致性(create_task 时 status 恒 todo,
/// 仅 backlog/todo/decision 合法;active/done 需经 move_task_queue 或 advance_task 流转)。
@@ -325,7 +325,7 @@ pub async fn create_task(
idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 多工程:可选关联到具体工程(module)
module_id: input.module_id.filter(|s| !s.is_empty()),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
// 经上方校验的 queue / parent_id / content_json
queue,
parent_id,
content_json,
@@ -337,7 +337,7 @@ pub async fn create_task(
.insert(record.clone())
.await
.map_err(err_str)?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_created 事件。best-effort 不阻断。
// task_created 事件。best-effort 不阻断。
emit_event(
&state,
&record.project_id,
@@ -591,34 +591,21 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
///
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
/// 落 df-nodes::task_advance_node,本命令只做参数转发与错误串化
/// 落 df-nodes::task_advance_node,本命令只做参数转发与结果包装
///
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
///
/// **知识图谱 Phase 1 V29 父聚合(设计 §2.1)**:推进走 `advance_task_with_parent`,推进完成后
/// 若任务有 parent_id,自动触发父任务 status 重算(recompute_parent_status,df-nodes 共享层
/// D3)。父任务=容器模型,status 不走状态机,由子任务聚合计算。聚合失败不阻断推进(best-effort,
/// 设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
///
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
/// 返回: `Result<TaskRecord, String>` — 成功返更新后的任务;失败 Err(状态机拒绝/CAS 冲突/
/// 库错误)走 Tauri reject,前端 `.catch` 捕获(与 list/update/create 单轨范式一致)。
#[tauri::command]
pub async fn advance_task(
state: State<'_, AppState>,
id: String,
target_status: String,
) -> Result<TaskRecord, String> {
// 知识图谱 Phase 2:推进前读当前态,作 task_advanced 事件 from_state(仅一次轻量读,
// advance 低频无压力)。失败(任务不存在)不阻断——后续 atomic 会用 NotFound 拒绝,from 留空。
let from_state = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.map(|t| t.status);
let from_state = match state.tasks.get_by_id(&id).await {
Ok(Some(t)) => Some(t.status),
_ => None,
};
// 推进 + 父聚合(df-nodes 共享层 D3,与 AI 工具/MCP 同源,消除双轨):
// 子任务推进成功后自动触发父 status 聚合(容器模型,聚合失败仅 warn 不阻断)。
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&state.tasks,
&id,
@@ -627,7 +614,6 @@ pub async fn advance_task(
.await
.map_err(err_str)?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
emit_event(
&state,
&updated.project_id,
@@ -643,7 +629,7 @@ pub async fn advance_task(
}
// ============================================================
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
// task_link CRUD IPC
// ============================================================
/// 创建任务横向关联(对标设计 §2.2,AI 拓扑排序编排调度的基础)。
@@ -737,7 +723,7 @@ pub async fn list_task_links(
}
// ============================================================
// 知识图谱 Phase 1:move_task_queue(跨池移动 + 一致性约束,对标设计 §2.1 + §五)
// move_task_queue(跨池移动 + 一致性约束)
// ============================================================
/// 跨池移动任务(对标设计 §2.1 queue 字段语义 + §五 move_task_queue)。
@@ -785,7 +771,7 @@ pub async fn move_task_queue(
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在"))?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
// queue 变化事件。best-effort 不阻断。
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
//
// M17:用独立 event_type "task_queue_moved"(非复用 "task_advanced"),与 status 推进事件
@@ -826,7 +812,7 @@ pub async fn move_task_queue(
}
// ============================================================
// 知识图谱 Phase 1:get_task_tree(父子任务树,对标设计 §2.1 + §五)
// get_task_tree(父子任务树)
// ============================================================
/// 任务树节点(父 + 子任务列表,对标设计 §2.1 限 1 级嵌套)。
+2 -2
View File
@@ -147,11 +147,11 @@ export interface PromotionResult {
// ============================================================
// TaskRecord.status union 类型 — 值集对齐后端 TaskStatus serde (df-types/src/types.rs:131)
// snake_case: todo | in_progress | in_review | testing | done | blocked | cancelled
// snake_case: todo | in_progress | in_review | testing | done | blocked | cancelled | deferred
// 注:constants/project.ts 的 mapLegacyStatus 兜底老 DB 旧态(completed/review_ready/merged/abandoned),
// 仅在 taskStatusLabel/taskStatusClass 辅助函数内映射,record.status 本身经后端 is_valid 拦截无旧态。
export type TaskStatus =
| 'todo' | 'in_progress' | 'in_review' | 'testing' | 'done' | 'blocked' | 'cancelled'
| 'todo' | 'in_progress' | 'in_review' | 'testing' | 'done' | 'blocked' | 'cancelled' | 'deferred'
export interface TaskRecord {
id: TaskId
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="task-batch-bar" v-if="count > 0">
<div class="tbb-info">
<span class="tbb-count">{{ $t('tasks.batch.selected', { n: count }) }}</span>
<button class="btn btn-ghost btn-sm" @click="$emit('clear')">{{ $t('tasks.batch.clear') }}</button>
</div>
<div class="tbb-actions">
<!-- 推进到 (仅渲染全部可达的交集目标) -->
<div class="tbb-menu" ref="advanceMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" @click.stop="toggleMenu('advance')">
{{ $t('tasks.batch.advanceTo') }}
</button>
<div v-if="activeMenu === 'advance'" class="tbb-dropdown" @click.stop>
<div v-if="advanceTargets.length === 0" class="tbb-dropdown-empty">{{ $t('tasks.batch.noCommonTarget') }}</div>
<button v-for="s in advanceTargets" :key="s.key" class="tbb-dropdown-item" @click="pick('advance', s.key)">
<span>{{ s.icon }}</span>{{ s.label }}
</button>
</div>
</div>
<button class="btn btn-ghost btn-sm" :disabled="busy || !cancelAvailable" @click="$emit('cancel')">{{ $t('tasks.batch.cancel') }}</button>
<button class="btn btn-ghost btn-sm" :disabled="busy || !deferAvailable" @click="$emit('defer')">{{ $t('tasks.batch.defer') }}</button>
<button class="btn btn-ghost btn-sm" :disabled="busy || !resumeAvailable" @click="$emit('resume')">{{ $t('tasks.batch.resume') }}</button>
<!-- 改优先级 -->
<div class="tbb-menu" ref="priorityMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('priority')">
{{ $t('tasks.batch.priority') }}
</button>
<div v-if="activeMenu === 'priority'" class="tbb-dropdown" @click.stop>
<button v-for="p in priorityOptions" :key="p.value" class="tbb-dropdown-item" @click="pick('priority', String(p.value))">
<span :class="p.cls"></span>{{ p.label }}
</button>
</div>
</div>
<!-- 指派 (内嵌输入 + 建议) -->
<div class="tbb-menu" ref="assigneeMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('assignee')">
{{ $t('tasks.batch.assignee') }}
</button>
<div v-if="activeMenu === 'assignee'" class="tbb-dropdown tbb-dropdown--assignee" @click.stop>
<input
v-model="assigneeInput"
class="tbb-assignee-input"
:placeholder="$t('tasks.batch.assigneePlaceholder')"
list="tbb-assignee-suggest"
@keydown.enter="confirmAssignee"
/>
<datalist id="tbb-assignee-suggest">
<option v-for="a in assigneeSuggestions" :key="a" :value="a" />
</datalist>
<button class="btn btn-primary btn-sm tbb-assignee-ok" :disabled="!assigneeInput.trim()" @click="confirmAssignee">
{{ $t('common.confirm') }}
</button>
</div>
</div>
<span v-if="busy" class="tbb-spinner" aria-hidden="true"></span>
</div>
<button class="btn btn-sm tbb-danger" :disabled="busy" @click="confirmDelete">{{ $t('tasks.batch.delete') }}</button>
</div>
<!-- toast 反馈(批量动作结果) -->
<Transition name="toast">
<div v-if="toast.visible" class="tbb-toast" :class="'tbb-toast--' + toast.type">{{ toast.msg }}</div>
</Transition>
<!-- 删除二次确认 -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="$t('common.delete')"
@result="answerConfirm"
/>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import type { ToastState } from '@/composables/useToast'
export interface AdvanceTargetOption {
key: string
icon: string
label: string
}
const props = defineProps<{
count: number
busy: boolean
/** 交集合法推进目标(空 = 无可统一推进) */
advanceTargets: AdvanceTargetOption[]
cancelAvailable: boolean
deferAvailable: boolean
resumeAvailable: boolean
/** 删除确认文案用的级联子任务数 */
cascadeEstimate: number
assigneeSuggestions?: string[]
/** 批量动作的 toast 状态(来自 useTaskBatchActions),本组件统一渲染 */
toast: ToastState
}>()
const emit = defineEmits<{
advance: [target: string]
cancel: []
defer: []
resume: []
priority: [value: string]
assignee: [value: string]
delete: []
clear: []
}>()
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
const priorityOptions = 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' },
])
// ---- () ----
const activeMenu = ref<'advance' | 'priority' | 'assignee' | ''>('')
const assigneeInput = ref('')
const advanceMenuRef = ref<HTMLElement | null>(null)
const priorityMenuRef = ref<HTMLElement | null>(null)
const assigneeMenuRef = ref<HTMLElement | null>(null)
function toggleMenu(menu: 'advance' | 'priority' | 'assignee') {
activeMenu.value = activeMenu.value === menu ? '' : menu
}
function onDocClick(e: MouseEvent) {
const inside =
advanceMenuRef.value?.contains(e.target as Node) ||
priorityMenuRef.value?.contains(e.target as Node) ||
assigneeMenuRef.value?.contains(e.target as Node)
if (!inside) activeMenu.value = ''
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
function pick(kind: 'advance' | 'priority', value: string) {
activeMenu.value = ''
if (kind === 'advance') emit('advance', value)
else emit('priority', value)
}
function confirmAssignee() {
const v = assigneeInput.value.trim()
if (!v) return
activeMenu.value = ''
assigneeInput.value = ''
emit('assignee', v)
}
function confirmDelete() {
const msg =
props.cascadeEstimate > 0
? t('tasks.batch.confirmDelete', { n: props.count, c: props.cascadeEstimate })
: t('tasks.batch.confirmDeletePlain', { n: props.count })
confirmDialog(msg, t('common.delete')).then(ok => {
if (ok) emit('delete')
})
}
</script>
<style scoped>
/* 批量操作栏:底部固定,居中对齐,选中任务时浮现 */
.task-batch-bar {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
max-width: 92vw;
}
.tbb-info { display: flex; align-items: center; gap: 8px; }
.tbb-count { font-size: 12px; color: var(--df-accent); white-space: nowrap; }
.tbb-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tbb-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: tbb-spin 0.9s linear infinite;
}
@keyframes tbb-spin { to { transform: rotate(360deg); } }
.tbb-danger {
background: transparent;
color: var(--df-danger);
border: 0.5px solid rgba(240, 101, 101, 0.4);
}
.tbb-danger:hover { background: rgba(240, 101, 101, 0.12); }
.tbb-danger:disabled { opacity: 0.4; cursor: not-allowed; }
/* 下拉菜单(向上弹出) */
.tbb-menu { position: relative; }
.tbb-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 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: 110;
padding: 6px 0;
}
.tbb-dropdown--assignee { min-width: 220px; padding: 10px; }
.tbb-dropdown-empty { font-size: 12px; color: var(--df-text-dim); padding: 4px 12px; }
.tbb-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 12px;
background: none;
border: none;
font-size: 13px;
color: var(--df-text);
cursor: pointer;
}
.tbb-dropdown-item:hover { background: var(--df-accent-bg); }
.tbb-assignee-input {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
font-size: 13px;
color: var(--df-text);
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
outline: none;
}
.tbb-assignee-input:focus { border-color: var(--df-accent); }
.tbb-assignee-ok { margin-top: 8px; width: 100%; }
/* toast(独立于操作栏定位,避免被栏遮挡) */
.tbb-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
padding: 8px 16px;
border-radius: var(--df-radius-sm);
font-size: 13px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 80vw;
}
.tbb-toast--success { border-color: rgba(82, 196, 26, 0.5); color: #67c23a; }
.tbb-toast--warning { border-color: rgba(230, 162, 60, 0.5); color: #e6a23c; }
.tbb-toast--error { border-color: rgba(240, 101, 101, 0.5); color: var(--df-danger); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; }
</style>
+355
View File
@@ -0,0 +1,355 @@
<template>
<Drawer
:visible="visible"
width="640px"
:mask="true"
:mask-closable="true"
:esc-to-close="true"
:footer="false"
@close="$emit('close')"
header-style="display:none"
>
<div class="task-drawer" v-if="task">
<!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 -->
<div class="dr-header">
<div class="dr-header-left">
<h1>{{ task.title }}</h1>
<div class="dr-badges">
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<span v-if="task.review_rounds > 0" class="review-rounds-badge">{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}</span>
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
</div>
</div>
<div class="dr-header-actions">
<button class="btn btn-ghost btn-sm" @click="reload">{{ $t('taskDetail.refresh') }}</button>
<button
v-for="act in advanceActions"
:key="act.target"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</div>
<!-- 描述 -->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
<div v-if="task.description" class="description-wrap">
<span class="value description ai-md" v-html="renderedDesc"></span>
</div>
<div v-else class="empty-hint">{{ $t('taskDetail.descEmpty') }}</div>
</section>
<!-- 关联信息 -->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
<div class="task-info">
<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" @click="$emit('close')">{{ parentTaskTitle || task.parent_id }}</router-link>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.project') }}</span>
<span class="value">
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link" @click="$emit('close')">{{ projectName }}</router-link>
<span v-else></span>
</span>
</div>
<div v-if="task.idea_id" class="info-item">
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
<span class="value">
<router-link :to="`/ideas/${task.idea_id}`" class="project-link" @click="$emit('close')">{{ ideaTitle }}</router-link>
</span>
</div>
<div v-if="task.branch_name" class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
<span class="branch-tag"><span class="branch-icon"></span>{{ task.branch_name }}</span>
</span>
</div>
<div v-if="task.assignee" class="info-item">
<span class="label">{{ $t('taskDetail.assignee') }}</span>
<span class="value">{{ task.assignee }}</span>
</div>
<div class="info-item timestamps">
<span class="timestamp">{{ $t('taskDetail.createdAt') }}: {{ formatDate(task.created_at) }}</span>
<span class="timestamp">{{ $t('taskDetail.updatedAt') }}: {{ formatDate(task.updated_at) }}</span>
</div>
</div>
</section>
<!-- 任务产出(折叠式) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('taskDetail.output') }}</h2>
<button class="btn btn-ghost btn-sm" @click="outputExpanded = !outputExpanded">{{ outputExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
</div>
<div v-if="outputExpanded">
<div v-if="!task.output_json" class="empty-hint"></div>
<TaskOutputCard v-else :output-json="task.output_json" />
</div>
<div v-else class="empty-hint">{{ task.output_json ? $t('taskDetail.expand') : '—' }}</div>
</section>
<!-- 子任务面板(仅顶层任务,简化版:无批量操作) -->
<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>
<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}`); $emit('close')">
<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>
</div>
</div>
<div v-else class="empty-hint subtask-empty">{{ $t('taskDetail.childEmpty') }}</div>
<button class="btn btn-ghost btn-sm subtask-add" @click="showSubtaskModal = true">{{ $t('taskDetail.addSubtask') }}</button>
</section>
<!-- 工作流进度(抽屉内简化:仅显示推进提示文本,不渲染完整 DAG) -->
<section v-if="wfSnap" class="panel wf-panel">
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
<div class="wf-progress">
<span v-if="wfSnap.runningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfSnap.runningNode }) }}</span>
<span v-else-if="wfSnap.doneCount > 0" class="wf-progress-count">{{ $t('taskDetail.workflowStepsProgress', { done: wfSnap.doneCount, total: wfSnap.totalNodes }) }}</span>
<span v-if="wfSnap.result === 'completed'" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-else-if="wfSnap.result === 'failed'" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
</section>
</div>
<!-- 加载态 -->
<div v-else-if="loading" class="empty-hint loading-hint">{{ $t('taskDetail.loading') }}</div>
<div v-else-if="errorMsg" class="empty-hint error-hint"> {{ errorMsg }}</div>
<!-- 子任务新建弹窗 -->
<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>
</Drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Drawer } from '@arco-design/web-vue'
import { taskApi, projectApi, ideaApi } from '@/api'
import { useProjectStore } from '@/stores/project'
import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown'
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
import type { AdvanceAction } from '@/constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
taskId: string | null
visible: boolean
}>()
const emit = defineEmits<{
close: []
'advance-done': []
}>()
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
const { showToast } = useToast()
const loading = ref(true)
const errorMsg = ref('')
const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([])
const ideas = ref<IdeaRecord[]>([])
const children = ref<TaskRecord[]>([])
const parentTaskTitle = ref<string | null>(null)
const advancing = ref(false)
const outputExpanded = ref(false)
//
const showSubtaskModal = ref(false)
const subtaskTitle = ref('')
const subtaskDesc = ref('')
const subtaskPriority = ref(2)
const subtaskSubmitting = ref(false)
const advanceActions = computed<AdvanceAction[]>(() => {
const s = task.value?.status
if (!s) return []
return ADVANCE_MAP[s] ?? []
})
//
const projectName = computed(() => {
if (!task.value) return ''
return projects.value.find(p => p.id === task.value!.project_id)?.name ?? ''
})
const ideaTitle = computed(() => {
if (!task.value?.idea_id) return ''
return ideas.value.find(i => i.id === task.value!.idea_id)?.title ?? task.value!.idea_id
})
const { rendered } = useRendered(
() => task.value?.description ?? '',
)
const renderedDesc = computed(() => rendered.value)
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 }
})
// ( store ,)
const wfSnap = computed(() => {
try {
return store.workflowProgress(null)
} catch { return null }
})
//
async function load() {
if (!props.taskId) return
loading.value = true
errorMsg.value = ''
try {
const [t, ps, ideasList] = await Promise.all([
taskApi.get(props.taskId),
projectApi.list(),
ideaApi.list(),
])
task.value = t
projects.value = ps
ideas.value = ideasList
await loadChildren()
await loadParentTitle()
} catch (e: any) {
errorMsg.value = e?.toString() ?? t('common.unknownError')
} finally {
loading.value = false
}
}
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 { children.value = [] }
}
async function loadParentTitle() {
const cur = task.value
if (!cur?.parent_id) { parentTaskTitle.value = null; return }
try {
const p = await taskApi.get(cur.parent_id)
parentTaskTitle.value = p.title
} catch { parentTaskTitle.value = null }
}
function reload() { load() }
//
async function handleAdvance(target: string) {
if (!task.value || advancing.value) return
advancing.value = true
try {
const updated = await taskApi.advance(task.value.id, target)
task.value = updated
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
emit('advance-done')
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
} finally {
advancing.value = false
}
}
watch(() => props.taskId, (id) => {
if (id) load()
})
//
async function confirmSubtask() {
if (!task.value || !subtaskTitle.value.trim()) return
subtaskSubmitting.value = true
try {
await taskApi.create({
project_id: task.value.project_id,
title: subtaskTitle.value.trim(),
description: subtaskDesc.value.trim(),
priority: subtaskPriority.value,
parent_id: task.value.id,
})
showSubtaskModal.value = false
subtaskTitle.value = ''
subtaskDesc.value = ''
await loadChildren()
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
} finally {
subtaskSubmitting.value = false
}
}
</script>
<style scoped>
.task-drawer { padding: 0; }
.dr-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 0.5px solid var(--df-border);
}
.dr-header-left { flex: 1; min-width: 0; }
.dr-header-left h1 {
font-size: 16px;
font-weight: 600;
color: var(--df-text);
margin: 0 0 8px;
line-height: 1.4;
word-break: break-all;
}
.dr-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.loading-hint { text-align: center; padding: 40px 0; }
</style>
+180
View File
@@ -0,0 +1,180 @@
/**
* (/··/)
*
* 设计:后端零改动, taskApi(advance CAS + can_transition +
* + review_rounds;delete ;update priority/assignee status)
* `TASK_STATUS_TRANSITIONS` ( TaskDetail ADVANCE_MAP ),
* skipped(),()
*
* busy + onRefresh + toast ;
* BatchActionResult ,toast
*/
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { taskApi } from '@/api/task'
import type { TaskRecord } from '@/api/types'
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
import { useToast } from '../useToast'
export interface BatchFailedItem {
id: string
title: string
error: string
}
export interface BatchSkippedItem {
id: string
title: string
reason: string
}
export interface BatchActionResult {
ok: number
failed: BatchFailedItem[]
/** 状态机非法被跳过(非失败,不打扰用户) */
skipped: BatchSkippedItem[]
/** deleteMany 级联软删的子任务数合计 */
cascaded?: number
}
/** 恢复类动作的目标态解析:deferred/cancelled→todo、blocked→in_progress、其余无恢复路径 */
const RESUME_TARGET: Record<string, string> = {
deferred: 'todo',
cancelled: 'todo',
blocked: 'in_progress',
}
/** 推进类动作的结果文案 key(tasks 命名空间,非删除场景) */
const LABEL_BY_KIND: Record<'advance' | 'update', string> = {
advance: 'tasks.batch.advanceResult',
update: 'tasks.batch.updateResult',
}
export function useTaskBatchActions(opts: {
/** 由 id 解析当前任务(标题/状态/父);解析不到视为已不存在记 failed */
getTask: (id: string) => TaskRecord | undefined
/** 操作完成后统一刷新(三视图各自:loadTasks / loadProjectTasks / loadChildren) */
onRefresh?: () => Promise<void> | void
toastDurationMs?: number
}) {
const { t } = useI18n()
const { toast, showToast } = useToast(opts.toastDurationMs)
const busy = ref(false)
function finalize(r: BatchActionResult, labelKey: string, durationMs?: number) {
busy.value = false
const { ok, failed, skipped } = r
if (failed.length === 0 && skipped.length === 0) {
const msg = r.cascaded != null ? t('tasks.batch.deleteResult', { n: ok, c: r.cascaded }) : t(labelKey, { n: ok })
showToast(msg, 'success', durationMs)
} else if (failed.length === 0) {
showToast(`${t(labelKey, { n: ok })}${t('tasks.batch.skipped', { n: skipped.length })}`, 'warning', durationMs)
} else {
showToast(`${t(labelKey, { n: ok })}${t('tasks.batch.failed', { n: failed.length })}`, 'error', durationMs)
}
}
/** 通用串行执行器:逐条 op,抛错=failed;skip 返回 reason 则跳过不进 API */
async function run(
ids: string[],
op: (id: string, task: TaskRecord) => Promise<unknown>,
skip: (task: TaskRecord) => string | null,
labelKey: string,
): Promise<BatchActionResult> {
busy.value = true
const result: BatchActionResult = { ok: 0, failed: [], skipped: [] }
try {
for (const id of ids) {
const task = opts.getTask(id)
if (!task) {
result.failed.push({ id, title: id, error: t('tasks.err.loadFailed') })
continue
}
const reason = skip(task)
if (reason) {
result.skipped.push({ id, title: task.title, reason })
continue
}
try {
await op(id, task)
result.ok++
} catch (e: any) {
result.failed.push({ id, title: task.title, error: e?.toString?.() ?? String(e) })
}
}
} finally {
try { if (opts.onRefresh) await opts.onRefresh() } catch {}
finalize(result, labelKey)
}
return result
}
/** 批量推进到统一目标态(取消/暂缓/恢复均经此) */
function advanceMany(ids: string[], target: string): Promise<BatchActionResult> {
return run(
ids,
id => taskApi.advance(id, target),
task => (TASK_STATUS_TRANSITIONS[task.status]?.includes(target) ? null : t('tasks.batch.noCommonTarget')),
LABEL_BY_KIND.advance,
)
}
function cancelMany(ids: string[]): Promise<BatchActionResult> {
return advanceMany(ids, 'cancelled')
}
function deferMany(ids: string[]): Promise<BatchActionResult> {
return advanceMany(ids, 'deferred')
}
/** 恢复:目标态随当前态解析(deferred/cancelled→todo、blocked→in_progress) */
function resumeMany(ids: string[]): Promise<BatchActionResult> {
return run(
ids,
(id, task) => taskApi.advance(id, RESUME_TARGET[task.status]),
task => (RESUME_TARGET[task.status] ? null : t('tasks.batch.noCommonTarget')),
LABEL_BY_KIND.advance,
)
}
/**
* ()+子同选去重:子随父由后端级联软删,;
* () taskApi.delete store ,
* store.error error banner( Tasks.vue quickDelete )
*/
async function deleteMany(ids: string[]): Promise<BatchActionResult> {
busy.value = true
const selected = new Set(ids)
const result: BatchActionResult = { ok: 0, failed: [], skipped: [], cascaded: 0 }
try {
for (const id of ids) {
const task = opts.getTask(id)
if (!task) {
result.failed.push({ id, title: id, error: t('tasks.err.loadFailed') })
continue
}
if (task.parent_id && selected.has(task.parent_id)) continue // 子随父级联,跳过避免重复删
try {
const res = await taskApi.delete(id)
if (res?.ok) {
result.ok++
result.cascaded = (result.cascaded ?? 0) + (res.cascaded ?? 0)
} else {
result.failed.push({ id, title: task.title, error: t('tasks.err.deleteFailed') })
}
} catch (e: any) {
result.failed.push({ id, title: task.title, error: e?.toString?.() ?? String(e) })
}
}
} finally {
if (opts.onRefresh) await opts.onRefresh()
finalize(result, 'tasks.batch.deleteResult')
}
return result
}
/** 批量更新字段(priority / assignee;status 禁走此,一律 advance) */
function updateMany(ids: string[], field: 'priority' | 'assignee', value: string): Promise<BatchActionResult> {
return run(ids, id => taskApi.update(id, field, value), () => null, LABEL_BY_KIND.update)
}
return { busy, toast, showToast, advanceMany, cancelMany, deferMany, resumeMany, deleteMany, updateMany }
}
@@ -0,0 +1,74 @@
/**
* Tasks / ProjectDetail / TaskDetail
*
* `Set<string>`(, Projects.vue , mutate)
* scopeIds = / id ();
*
* /子语义:勾选父任务只勾父自身,(,/)
*/
import { computed, ref, type ComputedRef, type Ref } from 'vue'
export interface TaskBatchSelection {
/** 选中 id 集(响应式 Set,写操作一律整体替换) */
selected: Ref<Set<string>>
count: ComputedRef<number>
/** 范围非空且全部勾选 */
allSelected: ComputedRef<boolean>
/** 有勾选但未全选(表头半选态) */
someSelected: ComputedRef<boolean>
isSelected: (id: string) => boolean
toggle: (id: string, checked: boolean) => void
toggleAll: (checked: boolean) => void
clear: () => void
/** 移除已不在 scopeIds 的 id(刷新/筛选后收敛幽灵选中),返回被清理数 */
prune: () => number
}
export function useTaskBatchSelection(opts: {
/** 当前可见可勾选 id 范围 */
scopeIds: ComputedRef<string[]>
}): TaskBatchSelection {
const selected = ref<Set<string>>(new Set())
const count = computed(() => selected.value.size)
const allSelected = computed(() => {
const scope = opts.scopeIds.value
return scope.length > 0 && scope.every(id => selected.value.has(id))
})
const someSelected = computed(() => count.value > 0 && !allSelected.value)
function isSelected(id: string): boolean {
return selected.value.has(id)
}
function toggle(id: string, checked: boolean) {
const next = new Set(selected.value)
if (checked) next.add(id)
else next.delete(id)
selected.value = next
}
function toggleAll(checked: boolean) {
selected.value = checked ? new Set(opts.scopeIds.value) : new Set()
}
function clear() {
selected.value = new Set()
}
function prune(): number {
const scope = new Set(opts.scopeIds.value)
let removed = 0
const next = new Set<string>()
for (const id of selected.value) {
if (scope.has(id)) next.add(id)
else removed++
}
if (removed > 0) selected.value = next
return removed
}
return { selected, count, allSelected, someSelected, isSelected, toggle, toggleAll, clear, prune }
}
+66
View File
@@ -0,0 +1,66 @@
import { ref } from 'vue'
import Sortable from 'sortablejs'
import { useI18n } from 'vue-i18n'
import { taskApi } from '@/api/task'
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
import { useToast } from '../useToast'
export function useTaskKanban(opts: {
onRefresh: () => Promise<void> | void
toastDurationMs?: number
}) {
const { t } = useI18n()
const { showToast } = useToast(opts.toastDurationMs)
const kanbanBusy = ref(false)
let s: Sortable[] = []
let _dragId: string | null = null
let _executing = false
function destroy() { s.forEach(x => x.destroy()); s = [] }
function setup(el: HTMLElement) {
destroy()
el.querySelectorAll('.kanban-col-body').forEach(body => {
s.push(new Sortable(body as HTMLElement, {
group: 'kanban',
draggable: '.task-card',
// 父卡禁拖:父状态=子聚合派生,拖动会被状态机强写,违反容器模型。
filter: '.kanban-empty, .task-card--parent',
forceFallback: true,
onMove: (e) => {
const from = (e.from as HTMLElement).dataset.status ?? ''
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status ?? ''
if (from === to) return true
return TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
},
onStart: (e) => {
_dragId = (e.item as HTMLElement).dataset.taskId ?? null
_executing = false
},
onEnd: async (e) => {
if (_executing) return
_executing = true
const from = (e.from as HTMLElement).dataset.status
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status
const id = _dragId
_dragId = null
if (!to || !from || to === from) { _executing = false; return }
if (!id) { _executing = false; return }
kanbanBusy.value = true
try {
await taskApi.advance(id, to)
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
} catch (err) {
showToast(err?.toString?.() ?? t('common.unknownError'), 'error')
} finally {
kanbanBusy.value = false
_executing = false
await opts.onRefresh()
}
},
}))
})
}
return { kanbanBusy, setupKanbanSortable: setup, destroyKanban: destroy }
}
+69 -7
View File
@@ -88,6 +88,7 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
done: 'tasks.status.done',
blocked: 'tasks.status.blocked',
cancelled: 'tasks.status.cancelled',
deferred: 'tasks.status.deferred',
}
// class 名对齐既有 css(Tasks/TaskDetail/ProjectDetail 均有 status-todo/progress/review/done/abandoned):
@@ -103,6 +104,7 @@ export const TASK_STATUS_CLASS: Record<string, string> = {
done: 'status-done',
blocked: 'status-blocked',
cancelled: 'status-abandoned',
deferred: 'status-deferred',
}
// D-260616-01 删旧 5 态前的老 DB 数据兼容映射(老 DB 可能存 completed/review_ready/merged/abandoned)
@@ -128,16 +130,76 @@ export function taskStatusClass(status: string): string {
}
// WK-8:任务状态机合法流转目标(源状态 → 可达目标态,对齐 TaskDetail.vue ADVANCE_MAP 的 target 集)。
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done/cancelled 为终态,无推进目标);
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done 为唯一终态,无推进目标;cancelled 可恢复);
// 新建/手动改状态仍走后端 TaskStatus::is_valid 兜底校验。
export const TASK_STATUS_TRANSITIONS: Record<string, string[]> = {
todo: ['in_progress', 'cancelled'],
in_progress: ['in_review', 'blocked', 'cancelled'],
in_review: ['testing', 'in_progress', 'blocked', 'cancelled'],
testing: ['done', 'in_review', 'blocked', 'cancelled'],
blocked: ['in_progress', 'cancelled'],
todo: ['in_progress', 'cancelled', 'deferred'],
in_progress: ['in_review', 'blocked', 'cancelled', 'deferred'],
in_review: ['testing', 'in_progress', 'blocked', 'cancelled', 'deferred'],
testing: ['done', 'in_review', 'blocked', 'cancelled', 'deferred'],
blocked: ['in_progress', 'cancelled', 'deferred'],
deferred: ['todo', 'cancelled'],
cancelled: ['todo'],
done: [],
cancelled: [],
}
// 状态语义排序(推进主链在前 → 暂停态 → 历史旁支),供状态多选 chips / 快捷菜单 / 看板列统一使用。
// 与后端 ALL_STATES / TASK_STATUS_TRANSITIONS 为同源集合,仅决定展示顺序。
export const TASK_STATUS_ORDER = [
'todo',
'in_progress',
'in_review',
'testing',
'done',
'blocked',
'deferred',
'cancelled',
] as const
/** 推进按钮映射(与 TASK_STATUS_TRANSITIONS 同源,附带 label i18n key + variant 样式)。 */
export interface AdvanceAction {
target: string
label: string
variant: string
}
export const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
todo: [
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_progress: [
{ target: 'in_review', label: 'taskDetail.advance.toInReview', variant: 'btn-primary' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_review: [
{ target: 'testing', label: 'taskDetail.advance.toTesting', variant: 'btn-primary' },
{ target: 'in_progress', label: 'taskDetail.advance.sendBack', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
testing: [
{ target: 'done', label: 'taskDetail.advance.toDone', variant: 'btn-primary' },
{ target: 'in_review', label: 'taskDetail.advance.sendBackToReview', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
blocked: [
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
deferred: [
{ target: 'todo', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
cancelled: [
{ target: 'todo', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
],
}
// ── 任务优先级 ──
+1
View File
@@ -41,6 +41,7 @@ export default {
sendBack: 'Send Back',
sendBackToReview: 'Send Back to Review',
block: 'Block',
defer: 'Defer',
cancel: 'Cancel Task',
},
advancing: 'Advancing...',
+35
View File
@@ -30,6 +30,7 @@ export default {
done: 'Done',
blocked: 'Blocked',
cancelled: 'Cancelled',
deferred: 'Deferred',
},
// Groups
@@ -53,6 +54,39 @@ export default {
quickPriority: 'Priority',
quickDelete: '🗑 Delete',
// Batch action bar (shared by Tasks / ProjectDetail / TaskDetail subtasks)
batch: {
selected: '{n} selected',
selectAll: 'Select All',
clear: 'Clear',
advanceTo: 'Move to',
noCommonTarget: 'No common target status for selected tasks',
cancel: 'Cancel',
defer: 'Defer',
resume: 'Resume',
priority: 'Priority',
assignee: 'Assignee',
assigneePlaceholder: 'Enter assignee…',
delete: 'Delete',
confirmDelete: 'Delete {n} tasks? {c} subtasks will be cascaded.',
confirmDeletePlain: 'Delete {n} tasks?',
deleteResult: 'Deleted {n} tasks ({c} subtasks cascaded)',
advanceResult: 'Advanced {n} tasks',
updateResult: 'Updated {n} tasks',
skipped: '{n} tasks skipped (not allowed)',
failed: '{n} failed',
selectHint: 'Select tasks for batch actions',
},
// Task kanban board
kanban: {
toggleList: 'List',
toggleKanban: 'Board',
emptyColumn: 'No tasks',
dragHandle: 'Drag to move',
parentNoDrag: 'Parent status is aggregated from subtasks and cannot be dragged',
},
// New task modal
modal: {
title: 'New Task',
@@ -85,6 +119,7 @@ export default {
done: '✅ Done',
blocked: '🚫 Blocked',
cancelled: '🗑️ Cancelled',
deferred: '⏰ Deferred',
},
// Error fallbacks (user-visible via state.error)
err: {
+1
View File
@@ -41,6 +41,7 @@ export default {
sendBack: '退回修改', // in_review → in_progress
sendBackToReview: '退回重审', // testing → in_review
block: '阻塞', // * → blocked
defer: '暂缓', // * → deferred
cancel: '取消任务', // * → cancelled
},
advancing: '推进中...',
+35
View File
@@ -30,6 +30,7 @@ export default {
done: '已完成',
blocked: '已阻塞',
cancelled: '已取消',
deferred: '已暂缓',
},
// 分组
@@ -53,6 +54,39 @@ export default {
quickPriority: '优先级',
quickDelete: '🗑 删除',
// 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务)
batch: {
selected: '已选 {n} 项',
selectAll: '全选',
clear: '清空',
advanceTo: '推进到',
noCommonTarget: '所选任务无可统一推进的状态',
cancel: '取消',
defer: '暂缓',
resume: '恢复',
priority: '改优先级',
assignee: '指派',
assigneePlaceholder: '输入负责人…',
delete: '删除',
confirmDelete: '确定批量删除 {n} 个任务吗?将级联删除 {c} 个子任务。',
confirmDeletePlain: '确定批量删除 {n} 个任务吗?',
deleteResult: '已删除 {n} 个任务(含级联 {c} 个子任务)',
advanceResult: '已推进 {n} 个任务',
updateResult: '已更新 {n} 个任务',
skipped: '{n} 个任务不允许该操作,已跳过',
failed: '失败 {n}',
selectHint: '勾选任务后批量操作',
},
// 任务看板
kanban: {
toggleList: '列表',
toggleKanban: '看板',
emptyColumn: '暂无任务',
dragHandle: '拖动以移动',
parentNoDrag: '父任务状态由子任务聚合,不能直接拖动',
},
// 新建任务模态
modal: {
title: '新建任务',
@@ -85,6 +119,7 @@ export default {
done: '✅ 已完成',
blocked: '🚫 已阻塞',
cancelled: '🗑️ 已取消',
deferred: '⏰ 已暂缓',
},
// 错误回退(state.error 用户可见)
err: {
+1
View File
@@ -62,6 +62,7 @@
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
/* 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享
IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致
+147 -12
View File
@@ -294,6 +294,16 @@
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label class="select-all" :title="$t('tasks.batch.selectHint')">
<input
type="checkbox"
:checked="selection.allSelected"
:indeterminate.prop="selection.someSelected"
@change="selection.toggleAll(($event.target as HTMLInputElement).checked)"
/>
{{ $t('tasks.filter.all') }}
</label>
</div>
<div class="task-filter-chips">
<button
@@ -313,6 +323,14 @@
:key="task.id"
@click="router.push('/tasks/' + task.id)"
>
<div class="task-card-checkbox">
<input
type="checkbox"
:checked="selection.isSelected(task.id)"
@click.stop
@change="selection.toggle(task.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div class="task-top">
<span class="task-title">{{ task.title }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
@@ -329,6 +347,27 @@
</div>
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<!-- 批量操作栏(选中任务后浮现) -->
<TaskBatchBar
v-if="selection.count > 0"
:count="selection.count"
:busy="batch.busy"
:advance-targets="batchAdvanceTargets"
:cancel-available="batchAvail.cancel"
:defer-available="batchAvail.defer"
:resume-available="batchAvail.resume"
:cascade-estimate="batchCascadeEstimate"
:toast="batch.toast"
:assignee-suggestions="assigneeSuggestions"
@advance="batch.advanceMany([...selection.selected], $event).then(afterBatch)"
@cancel="batch.cancelMany([...selection.selected]).then(afterBatch)"
@defer="batch.deferMany([...selection.selected]).then(afterBatch)"
@resume="batch.resumeMany([...selection.selected]).then(afterBatch)"
@priority="batch.updateMany([...selection.selected], 'priority', $event).then(afterBatch)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event).then(afterBatch)"
@delete="batch.deleteMany([...selection.selected]).then(afterBatch)"
@clear="selection.clear()"
/>
</section>
</div>
@@ -423,7 +462,7 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, reactive, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
@@ -435,13 +474,16 @@ import { moduleApi, type ProjectModuleRecord } from '@/api/module'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
import { projectStatusLabel, taskStatusLabel, taskStatusClass, TASK_STATUS_TRANSITIONS, TASK_STATUS_ORDER } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
@@ -588,16 +630,20 @@ async function loadProjectTasks() {
}
// taskStatusLabel / taskStatusClass ../constants/project
//
const taskStatusFilters = 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') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
])
// ( TASK_STATUS_ORDER )
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
todo: { icon: '📝', labelKey: 'tasks.statusFilter.todo' },
in_progress: { icon: '🔨', labelKey: 'tasks.statusFilter.in_progress' },
in_review: { icon: '👀', labelKey: 'tasks.statusFilter.in_review' },
testing: { icon: '🧪', labelKey: 'tasks.statusFilter.testing' },
done: { icon: '✅', labelKey: 'tasks.statusFilter.done' },
blocked: { icon: '🚫', labelKey: 'tasks.statusFilter.blocked' },
deferred: { icon: '', labelKey: 'tasks.statusFilter.deferred' },
cancelled: { icon: '🗑️', labelKey: 'tasks.statusFilter.cancelled' },
}
const taskStatusFilters = computed(() =>
TASK_STATUS_ORDER.map(key => ({ key, icon: STATUS_FILTER_META[key].icon, label: t(STATUS_FILTER_META[key].labelKey) })),
)
const activeTaskStatuses = ref<string[]>([])
function toggleTaskStatus(key: string) {
const idx = activeTaskStatuses.value.indexOf(key)
@@ -612,6 +658,68 @@ const filteredProjectTasks = computed(() => {
return projectTasks.value.filter(t => activeTaskStatuses.value.includes(t.status))
})
// ();reactive 使
const selection = reactive(useTaskBatchSelection({
scopeIds: computed(() => filteredProjectTasks.value.map(t => t.id)),
}))
const batch = reactive(useTaskBatchActions({
getTask: id => projectTasks.value.find(t => t.id === id),
onRefresh: () => loadProjectTasks(),
}))
async function afterBatch() {
selection.prune()
}
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
let common: Set<string> | null = null
for (const id of ids) {
const task = projectTasks.value.find(t => t.id === id)
if (!task) continue
const legal = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
if (common === null) {
common = legal
} else {
const next = new Set<string>()
for (const s of common) if (legal.has(s)) next.add(s)
common = next
}
if (common.size === 0) return []
}
return taskStatusFilters.value.filter(s => common?.has(s.key))
})
const RESUME_STATES = new Set(['deferred', 'cancelled', 'blocked'])
const batchAvail = computed(() => {
let cancel = false
let defer = false
let resume = false
for (const id of selection.selected) {
const task = projectTasks.value.find(t => t.id === id)
if (!task) continue
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('cancelled')) cancel = true
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('deferred')) defer = true
if (RESUME_STATES.has(task.status)) resume = true
}
return { cancel, defer, resume }
})
const batchCascadeEstimate = computed(() => {
const selected = selection.selected
let n = 0
for (const id of selected) {
const task = projectTasks.value.find(t => t.id === id)
if (!task || task.parent_id) continue
for (const c of projectTasks.value) {
if (c.parent_id === id && !selected.has(c.id)) n++
}
}
return n
})
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const t of projectTasks.value) if (t.assignee) set.add(t.assignee)
return [...set]
})
// ( CRUD)
// (monorepo/) + ; moduleApi + ConfirmDialog()
// + arco Message() Tab FileExplorer v-if ,
@@ -1400,6 +1508,8 @@ onUnmounted(() => {
color: var(--df-accent);
border-color: var(--df-accent);
}
/* 已暂缓状态 badge */
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
/* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签) */
.global-event-tag {
@@ -1413,6 +1523,31 @@ onUnmounted(() => {
/* ===== 任务卡片 ===== */
.task-list { display: flex; flex-direction: column; }
/* 批量操作:全选 + 行勾选(checkbox 绝对定位,卡片内容右移避开) */
.select-all {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.select-all input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.task-card { position: relative; }
.task-card-checkbox {
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: flex;
}
.task-card-checkbox input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.task-top, .task-branch, .task-meta { padding-left: 18px; }
.task-card {
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
+129 -45
View File
@@ -144,6 +144,16 @@
<div class="panel-header">
<h2>{{ $t('taskDetail.childrenTitle') }}</h2>
<span class="subtask-count">{{ $t('taskDetail.subtaskCount', { n: children.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label v-if="children.length > 0" class="select-all" :title="$t('tasks.batch.selectHint')">
<input
type="checkbox"
:checked="selection.allSelected"
:indeterminate.prop="selection.someSelected"
@change="selection.toggleAll(($event.target as HTMLInputElement).checked)"
/>
{{ $t('tasks.filter.all') }}
</label>
</div>
<!-- 顶部进度条 + 计数(done+cancelled / total) -->
<div v-if="children.length > 0" class="subtask-progress">
@@ -159,6 +169,13 @@
class="subtask-item"
@click="router.push(`/tasks/${child.id}`)"
>
<input
type="checkbox"
class="subtask-checkbox"
:checked="selection.isSelected(child.id)"
@click.stop
@change="selection.toggle(child.id, ($event.target as HTMLInputElement).checked)"
/>
<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>
@@ -184,6 +201,27 @@
<!-- 空态:父任务无子任务 -->
<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>
<!-- 批量操作栏(选中子任务后浮现) -->
<TaskBatchBar
v-if="selection.count > 0"
:count="selection.count"
:busy="batch.busy"
:advance-targets="batchAdvanceTargets"
:cancel-available="batchAvail.cancel"
:defer-available="batchAvail.defer"
:resume-available="batchAvail.resume"
:cascade-estimate="batchCascadeEstimate"
:toast="batch.toast"
:assignee-suggestions="assigneeSuggestions"
@advance="batch.advanceMany([...selection.selected], $event).then(afterBatch)"
@cancel="batch.cancelMany([...selection.selected]).then(afterBatch)"
@defer="batch.deferMany([...selection.selected]).then(afterBatch)"
@resume="batch.resumeMany([...selection.selected]).then(afterBatch)"
@priority="batch.updateMany([...selection.selected], 'priority', $event).then(afterBatch)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event).then(afterBatch)"
@delete="batch.deleteMany([...selection.selected]).then(afterBatch)"
@clear="selection.clear()"
/>
</section>
</div>
@@ -245,7 +283,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { ref, computed, reactive, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
@@ -259,10 +297,17 @@ import {
taskStatusClass,
priorityLabel,
priorityClass,
TASK_STATUS_TRANSITIONS,
TASK_STATUS_ORDER,
ADVANCE_MAP,
} from '../constants/project'
import type { AdvanceAction } from '../constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
const { t } = useI18n()
const route = useRoute()
@@ -293,15 +338,21 @@ function toggleChildMenu(id: string) {
}
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') },
])
// quickStatuses/quickPriorities ()
// = TASK_STATUS_ORDER 8 ( deferred/cancelled, ADVANCE_MAP )
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
todo: { icon: '📝', labelKey: 'tasks.statusFilter.todo' },
in_progress: { icon: '🔨', labelKey: 'tasks.statusFilter.in_progress' },
in_review: { icon: '👀', labelKey: 'tasks.statusFilter.in_review' },
testing: { icon: '🧪', labelKey: 'tasks.statusFilter.testing' },
done: { icon: '', labelKey: 'tasks.statusFilter.done' },
blocked: { icon: '🚫', labelKey: 'tasks.statusFilter.blocked' },
deferred: { icon: '⏰', labelKey: 'tasks.statusFilter.deferred' },
cancelled: { icon: '🗑️', labelKey: 'tasks.statusFilter.cancelled' },
}
const quickStatuses = computed(() =>
TASK_STATUS_ORDER.map(key => ({ key, icon: STATUS_FILTER_META[key].icon, label: t(STATUS_FILTER_META[key].labelKey) })),
)
/** 子任务快捷推进目标 = 状态机合法下一态(ADVANCE_MAP 过滤,与顶部手动按钮同源) */
function quickStatusesFor(child: TaskRecord) {
const valid = new Set((ADVANCE_MAP[child.status] ?? []).map((a) => a.target))
@@ -333,6 +384,58 @@ async function loadChildren() {
}
}
// ;reactive 使
const selection = reactive(useTaskBatchSelection({
scopeIds: computed(() => children.value.map(c => c.id)),
}))
const batch = reactive(useTaskBatchActions({
getTask: id => children.value.find(c => c.id === id),
onRefresh: () => loadChildren(),
}))
async function afterBatch() {
selection.prune()
}
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
let common: Set<string> | null = null
for (const id of ids) {
const task = children.value.find(c => c.id === id)
if (!task) continue
const legal = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
if (common === null) {
common = legal
} else {
const next = new Set<string>()
for (const s of common) if (legal.has(s)) next.add(s)
common = next
}
if (common.size === 0) return []
}
return quickStatuses.value.filter(s => common?.has(s.key))
})
const RESUME_STATES = new Set(['deferred', 'cancelled', 'blocked'])
const batchAvail = computed(() => {
let cancel = false
let defer = false
let resume = false
for (const id of selection.selected) {
const task = children.value.find(c => c.id === id)
if (!task) continue
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('cancelled')) cancel = true
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('deferred')) defer = true
if (RESUME_STATES.has(task.status)) resume = true
}
return { cancel, defer, resume }
})
// (), 0
const batchCascadeEstimate = computed(() => 0)
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const c of children.value) if (c.assignee) set.add(c.assignee)
return [...set]
})
/** 取父任务标题(父面包屑用;取不到时回退显示父 id) */
async function loadParentTitle() {
const cur = task.value
@@ -555,41 +658,6 @@ const ideaTitle = computed(() => {
// testing done(), in_review(退), blocked, cancelled
// blocked in_progress(), cancelled
// done/cancelled ,
// variant: primary=(),danger=,ghost=(/退/)
interface AdvanceAction {
target: string
label: string // i18n key( taskDetail.advance.*)
variant: string // btn-primary | btn-danger | btn-ghost
}
const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
todo: [
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_progress: [
{ target: 'in_review', label: 'taskDetail.advance.toInReview', variant: 'btn-primary' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_review: [
{ target: 'testing', label: 'taskDetail.advance.toTesting', variant: 'btn-primary' },
{ target: 'in_progress', label: 'taskDetail.advance.sendBack', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
testing: [
{ target: 'done', label: 'taskDetail.advance.toDone', variant: 'btn-primary' },
{ target: 'in_review', label: 'taskDetail.advance.sendBackToReview', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
blocked: [
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
// done / cancelled:,
}
const advanceActions = computed<AdvanceAction[]>(() => {
const s = task.value?.status
if (!s) return []
@@ -812,6 +880,7 @@ onBeforeUnmount(() => {
/* D-260616-01:7 态新增 — testing(橙警告,近 review 区分)/blocked(红 danger,与 cancelled 区分用实心边框)。配色与 Tasks.vue 一致保持跨视图统一 */
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
.priority-badge {
font-size: 10px; font-weight: 500;
@@ -958,6 +1027,21 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
}
/* 子任务批量勾选 + 全选 */
.subtask-checkbox { flex-shrink: 0; width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; margin: 0; }
.select-all {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.select-all input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.subtask-item {
display: flex;
align-items: center;
+420 -24
View File
@@ -29,21 +29,21 @@
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="filter-group">
<div class="filter-group" v-if="viewMode === 'list'">
<span class="filter-label">{{ $t('tasks.filter.status') }}</span>
<div class="filter-chip-group">
<button
v-for="s in statusFilters"
:key="s.key"
class="filter-chip"
:class="{ 'is-active': activeStatuses.length === 0 || activeStatuses.includes(s.key) }"
:class="{ 'is-active': s.key === 'all' ? activeStatuses.length === 0 : activeStatuses.includes(s.key) }"
@click="toggleStatus(s.key)"
>
{{ s.icon }} {{ s.label }}
</button>
</div>
</div>
<div class="filter-group">
<div class="filter-group" v-if="viewMode === 'list'">
<span class="filter-label">{{ $t('tasks.sort.label') }}</span>
<select v-model="sortBy" class="filter-select">
<option value="updated_at">{{ $t('tasks.sort.updated') }}</option>
@@ -53,10 +53,25 @@
</select>
</div>
<span class="filter-count">{{ $t('common.pagination.total', { n: store.tasks.length }) }}</span>
<!-- 列表/看板切换(持久化) -->
<div class="filter-group view-toggle">
<button class="seg-btn" :class="{ 'is-active': viewMode === 'list' }" @click="viewMode = 'list'">{{ $t('tasks.kanban.toggleList') }}</button>
<button class="seg-btn" :class="{ 'is-active': viewMode === 'kanban' }" @click="viewMode = 'kanban'">{{ $t('tasks.kanban.toggleKanban') }}</button>
</div>
<!-- 列表模式全选(看板无批量栏,隐藏) -->
<label v-if="viewMode === 'list'" class="select-all" :title="$t('tasks.batch.selectHint')">
<input
type="checkbox"
:checked="selection.allSelected"
:indeterminate.prop="selection.someSelected"
@change="selection.toggleAll(($event.target as HTMLInputElement).checked)"
/>
{{ $t('tasks.batch.selectAll') }}
</label>
</div>
<!-- 任务分组列表 -->
<div class="task-groups">
<!-- 任务分组列表(列表模式) -->
<div class="task-groups" v-if="viewMode === 'list'">
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="filteredGroups.length === 0" class="empty-state">
<div class="empty-icon">📋</div>
@@ -86,6 +101,13 @@
:class="{ 'is-expanded': expandedParents.has(row.task.id) }"
@click="router.push(`/tasks/${row.task.id}`)"
>
<input
type="checkbox"
class="task-checkbox"
:checked="selection.isSelected(row.task.id)"
@click.stop
@change="selection.toggle(row.task.id, ($event.target as HTMLInputElement).checked)"
/>
<div class="task-main">
<div class="task-title-row">
<button
@@ -147,6 +169,13 @@
class="task-item task-item-child"
@click="router.push(`/tasks/${child.id}`)"
>
<input
type="checkbox"
class="task-checkbox"
:checked="selection.isSelected(child.id)"
@click.stop
@change="selection.toggle(child.id, ($event.target as HTMLInputElement).checked)"
/>
<div class="task-main">
<div class="task-title-row">
<span class="child-dot"></span>
@@ -189,6 +218,13 @@
class="task-item"
@click="router.push(`/tasks/${row.task.id}`)"
>
<input
type="checkbox"
class="task-checkbox"
:checked="selection.isSelected(row.task.id)"
@click.stop
@change="selection.toggle(row.task.id, ($event.target as HTMLInputElement).checked)"
/>
<div class="task-main">
<div class="task-title-row">
<span class="task-title">{{ row.task.title }}</span>
@@ -230,6 +266,63 @@
</div>
<!-- F-260805 D5:列表页改一次性加载 + 前端组装树,移除真分页 Paginator(分页会割裂父/) -->
<!-- 任务看板:始终渲染, CSS 隐藏/显示( display:none,Sortable 需可见 DOM 初始化) -->
<div
class="kanban-board"
ref="kanbanBoardRef"
:class="{ 'is-busy': kanban.kanbanBusy, 'is-hidden': viewMode !== 'kanban' }"
>
<div v-for="col in kanbanColumns" :key="col.status" class="kanban-col" :data-status="col.status">
<div class="kanban-col-header">
<span class="kanban-col-name">{{ $t(statusLabel(col.status)) }}</span>
<span class="kanban-col-count">{{ col.tasks.length }}</span>
</div>
<div class="kanban-col-body" :data-status="col.status">
<div
v-for="task in col.tasks"
:key="task.id"
class="task-card"
:class="{ 'task-card--parent': parentIdSet.has(task.id) }"
:data-task-id="task.id"
:title="parentIdSet.has(task.id) ? $t('tasks.kanban.parentNoDrag') : undefined"
>
<div class="task-card-top">
<span class="task-card-title" @click.stop="openDrawer(task.id)">{{ task.title }}</span>
</div>
<div class="task-card-meta">
<span v-if="task.parent_id" class="parent-tag" :title="parentTitle(task.parent_id)"> {{ parentTitle(task.parent_id) }}</span>
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<span v-if="task.assignee" class="assignee-tag">{{ task.assignee }}</span>
</div>
</div>
<div v-if="col.tasks.length === 0" class="kanban-empty">{{ $t('tasks.kanban.emptyColumn') }}</div>
</div>
</div>
</div>
<!-- 批量操作栏(选中任务后浮现;看板模式无批量) -->
<TaskBatchBar
v-if="viewMode === 'list' && selection.count > 0"
:count="selection.count"
:busy="batch.busy"
:advance-targets="batchAdvanceTargets"
:cancel-available="batchAvail.cancel"
:defer-available="batchAvail.defer"
:resume-available="batchAvail.resume"
:cascade-estimate="batchCascadeEstimate"
:toast="batch.toast"
:assignee-suggestions="assigneeSuggestions"
@advance="batch.advanceMany([...selection.selected], $event)"
@cancel="batch.cancelMany([...selection.selected])"
@defer="batch.deferMany([...selection.selected])"
@resume="batch.resumeMany([...selection.selected])"
@priority="batch.updateMany([...selection.selected], 'priority', $event)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event)"
@delete="batch.deleteMany([...selection.selected])"
@clear="selection.clear()"
/>
<!-- 新建任务模态框(统一样式,去内联 style) -->
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
<div class="modal-box">
@@ -290,22 +383,35 @@
<!-- 确认弹层(删除任务,替代原生 window.confirm) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
<!-- 任务详情抽屉(看板卡片点击弹出) -->
<TaskDrawer
:task-id="drawerTaskId"
:visible="drawerVisible"
@close="closeDrawer()"
@advance-done="store.loadTasks(buildTaskQuery())"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, reactive } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, reactive, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { formatRelative } from '@/utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass, TASK_STATUS_TRANSITIONS } from '../constants/project'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass, TASK_STATUS_TRANSITIONS, TASK_STATUS_ORDER } from '../constants/project'
import { taskApi } from '@/api'
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import TaskDrawer from '@/components/task/TaskDrawer.vue'
import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef'
import { useToast } from '@/composables/useToast'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import { useTaskKanban } from '@/composables/task/useTaskKanban'
const router = useRouter()
const store = useProjectStore()
@@ -318,13 +424,23 @@ const { toast, showToast } = useToast()
// collapsedGroups df-tasks-collapsed(沿)
const activeProject = usePersistedRef('tasks.activeProject', 'all')
const activeStatuses = usePersistedRef<string[]>('tasks.activeStatuses', [])
// /(;,)
const viewMode = usePersistedRef<'list' | 'kanban'>('tasks.viewMode', 'list')
//
// :(==);
// ()
function toggleStatus(key: string) {
if (key === 'all') {
activeStatuses.value = []
return
}
const idx = activeStatuses.value.indexOf(key)
if (idx >= 0) {
activeStatuses.value.splice(idx, 1)
} else {
// ,()
const allIdx = activeStatuses.value.indexOf('all')
if (allIdx >= 0) activeStatuses.value.splice(allIdx, 1)
activeStatuses.value.push(key)
}
}
@@ -361,15 +477,20 @@ const quickMenuId = ref<string | null>(null)
function toggleQuickMenu(id: string) {
quickMenuId.value = quickMenuId.value === id ? null : id
}
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') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
])
/** 状态图标+文案键(排序由 TASK_STATUS_ORDER 统一驱动,看板列/快捷菜单/状态多选共用) */
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
todo: { icon: '📝', labelKey: 'tasks.statusFilter.todo' },
in_progress: { icon: '🔨', labelKey: 'tasks.statusFilter.in_progress' },
in_review: { icon: '👀', labelKey: 'tasks.statusFilter.in_review' },
testing: { icon: '🧪', labelKey: 'tasks.statusFilter.testing' },
done: { icon: '', labelKey: 'tasks.statusFilter.done' },
blocked: { icon: '🚫', labelKey: 'tasks.statusFilter.blocked' },
deferred: { icon: '⏰', labelKey: 'tasks.statusFilter.deferred' },
cancelled: { icon: '🗑️', labelKey: 'tasks.statusFilter.cancelled' },
}
const quickStatuses = computed(() =>
TASK_STATUS_ORDER.map(key => ({ key, icon: STATUS_FILTER_META[key].icon, label: t(STATUS_FILTER_META[key].labelKey) })),
)
/** WK-8:快捷状态目标 = 状态机合法流转目标(TASK_STATUS_TRANSITIONS 过滤,对齐 TaskDetail ADVANCE_MAP) */
function quickStatusesFor(task: TaskRecord): { key: string; icon: string; label: string }[] {
const valid = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
@@ -474,13 +595,7 @@ interface TaskRow {
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
{ key: 'in_review', label: t('tasks.statusFilter.in_review'), icon: '👀' },
{ key: 'testing', label: t('tasks.statusFilter.testing'), icon: '🧪' },
{ key: 'done', label: t('tasks.statusFilter.done'), icon: '✅' },
{ key: 'blocked', label: t('tasks.statusFilter.blocked'), icon: '🚫' },
{ key: 'cancelled', label: t('tasks.statusFilter.cancelled'), icon: '🗑️' },
...TASK_STATUS_ORDER.map(key => ({ key, label: t(STATUS_FILTER_META[key].labelKey), icon: STATUS_FILTER_META[key].icon })),
])
function getProjectName(projectId: string): string {
@@ -566,6 +681,117 @@ const filteredGroups = computed(() => {
return result
})
// ( = ,,v-show DOM)
const batchScopeIds = computed(() => {
const ids: string[] = []
for (const g of filteredGroups.value) {
for (const row of g.rows) {
ids.push(row.task.id)
for (const c of row.children) ids.push(c.id)
}
}
return ids
})
// reactive :(count/allSelected/busy/kanbanRenderKey ), .value
const selection = reactive(useTaskBatchSelection({ scopeIds: batchScopeIds }))
const batch = reactive(useTaskBatchActions({
getTask: id => store.tasks.find(t => t.id === id),
onRefresh: () => store.loadTasks(buildTaskQuery()),
}))
// (////),
// ,
watch(batchScopeIds, () => { selection.prune() })
/** 批量推进目标 = 选中集各任务合法目标的交集(全部可达才展示,点了就能全成功);含 done 则交集空 */
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
let common: Set<string> | null = null
for (const id of ids) {
const task = store.tasks.find(t => t.id === id)
if (!task) continue
const legal = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
if (common === null) {
common = legal
} else {
const next = new Set<string>()
for (const s of common) if (legal.has(s)) next.add(s)
common = next
}
if (common.size === 0) return []
}
return quickStatuses.value.filter(s => common?.has(s.key))
})
/** 恢复类动作可用状态(deferred/cancelled→todo、blocked→in_progress,对齐 useTaskBatchActions.RESUME_TARGET) */
const RESUME_STATES = new Set(['deferred', 'cancelled', 'blocked'])
const batchAvail = computed(() => {
let cancel = false
let defer = false
let resume = false
for (const id of selection.selected) {
const task = store.tasks.find(t => t.id === id)
if (!task) continue
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('cancelled')) cancel = true
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('deferred')) defer = true
if (RESUME_STATES.has(task.status)) resume = true
}
return { cancel, defer, resume }
})
/** 删除确认文案的级联子任务数 = 选中父任务的未选中直接子任务数(选中的子随父级联,不再单独计数) */
const batchCascadeEstimate = computed(() => {
const selected = selection.selected
let n = 0
for (const id of selected) {
const task = store.tasks.find(t => t.id === id)
if (!task || task.parent_id) continue
for (const c of store.tasks) {
if (c.parent_id === id && !selected.has(c.id)) n++
}
}
return n
})
/** 指派建议 = 现有任务 assignee 去重 */
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const t of store.tasks) if (t.assignee) set.add(t.assignee)
return [...set]
})
// (/,Sortable )
const kanban = reactive(useTaskKanban({
onRefresh: () => store.loadTasks(buildTaskQuery()),
}))
const kanbanBoardRef = ref<HTMLElement | null>(null)
const kanbanColumns = computed(() =>
TASK_STATUS_ORDER.map(status => ({
status,
tasks: store.tasks.filter(t => t.status === status),
})),
)
// parent id :,(=,)
const parentIdSet = computed(() => {
const set = new Set<string>()
for (const t of store.tasks) if (t.parent_id) set.add(t.parent_id)
return set
})
function parentTitle(pid: string): string {
return store.tasks.find(t => t.id === pid)?.title ?? ''
}
function kanbanRefresh() {
nextTick(() => {
if (kanbanBoardRef.value) kanban.setupKanbanSortable(kanbanBoardRef.value)
})
}
// ()
const drawerTaskId = ref<string | null>(null)
const drawerVisible = computed(() => drawerTaskId.value !== null)
function openDrawer(id: string) { drawerTaskId.value = id }
function closeDrawer() { drawerTaskId.value = null }
function buildTaskQuery(): TaskQuery | undefined {
const projAll = activeProject.value === 'all'
const kw = searchKeyword.value.trim()
@@ -649,6 +875,28 @@ function onKeydown(e: KeyboardEvent) {
}
}
// :requestAnimationFrame+setTimeout , DOM Sortable
watch(viewMode, v => {
if (v === 'kanban') {
selection.clear()
window.requestAnimationFrame(() => {
setTimeout(() => {
if (kanbanBoardRef.value) kanban.setupKanbanSortable(kanbanBoardRef.value)
}, 100)
})
} else {
kanban.destroyKanban()
}
})
// Sortable
watch(() => store.tasks, () => {
if (viewMode.value === 'kanban' && kanbanBoardRef.value) {
window.requestAnimationFrame(() => {
if (kanbanBoardRef.value) kanban.setupKanbanSortable(kanbanBoardRef.value)
})
}
})
onMounted(async () => {
store.setActiveTaskProject(activeProject.value)
store.setActiveTaskStatus(activeStatuses.value.length === 0 ? 'all' : activeStatuses.value.join(','))
@@ -665,6 +913,7 @@ onMounted(async () => {
} finally {
loading.value = false
}
if (viewMode.value === 'kanban') kanbanRefresh()
window.addEventListener('keydown', onKeydown)
document.addEventListener('click', closeQuickMenu)
})
@@ -673,12 +922,159 @@ onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.removeEventListener('click', closeQuickMenu)
if (_searchTimer) clearTimeout(_searchTimer)
kanban.destroyKanban()
})
</script>
<style scoped>
.tasks { padding: 16px 20px 20px; }
/* 列表/看板切换分段按钮 */
.view-toggle { margin-left: auto; display: flex; gap: 0; }
.seg-btn {
padding: 4px 12px;
font-size: 12px;
color: var(--df-text-dim);
background: var(--df-bg);
border: 0.5px solid var(--df-border);
cursor: pointer;
transition: all 0.15s;
}
.seg-btn:first-child { border-radius: var(--df-radius-sm) 0 0 var(--df-radius-sm); }
.seg-btn:last-child { border-radius: 0 var(--df-radius-sm) var(--df-radius-sm) 0; }
.seg-btn.is-active { color: var(--df-accent); background: var(--df-accent-bg); border-color: var(--df-accent); }
/* 全选 */
.select-all {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
white-space: nowrap;
user-select: none;
}
/* 行勾选 checkbox(点击行跳详情,checkbox 已 @click.stop 拦截) */
.task-checkbox {
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: var(--df-accent);
cursor: pointer;
margin: 0;
}
/* ══ 任务看板 ══ */
.kanban-board {
display: flex;
gap: 10px;
align-items: flex-start;
overflow-x: auto;
padding-bottom: 8px;
}
.kanban-board.is-busy .task-card { pointer-events: none; }
.kanban-board.is-hidden { display: none; }
.kanban-col {
flex: 1 0 220px;
min-width: 220px;
max-width: 280px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 220px);
}
.kanban-col-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.kanban-col-icon { font-size: 13px; }
.kanban-col-name { font-size: 13px; font-weight: 500; color: var(--df-text); flex: 1; }
.kanban-col-count {
font-size: 11px;
color: var(--df-text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 1px 8px;
border-radius: 8px;
}
.kanban-col-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
min-height: 120px;
overflow-y: auto;
}
.kanban-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
font-size: 12px;
color: var(--df-text-dim);
border: 1px dashed var(--df-border);
border-radius: var(--df-radius-sm);
}
.task-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 8px 10px;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
}
.task-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); border-color: var(--df-accent); }
.task-card.sortable-ghost { opacity: 0.5; }
/* 父卡禁拖:去掉抓取光标、左侧描边提示这是聚合容器卡 */
.task-card--parent { cursor: default; border-left: 2px solid var(--df-accent); }
.task-card-top { display: flex; align-items: flex-start; gap: 6px; }
.task-card-title {
flex: 1;
font-size: 13px;
color: var(--df-text);
line-height: 1.4;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-card-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.parent-tag {
font-size: 11px;
color: var(--df-text-dim);
background: rgba(255, 255, 255, 0.05);
padding: 1px 6px;
border-radius: 6px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assignee-tag {
font-size: 11px;
color: var(--df-text-dim);
border: 0.5px solid var(--df-border);
padding: 1px 6px;
border-radius: 6px;
}
/* page-header / page-header h1 / header-actions 已提取到全局 global.css */
/* 搜索框 */