diff --git a/crates/df-mcp/src/server.rs b/crates/df-mcp/src/server.rs index 9c42445..4c6d9e3 100644 --- a/crates/df-mcp/src/server.rs +++ b/crates/df-mcp/src/server.rs @@ -152,13 +152,35 @@ where let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await; write_response(&mut writer, &resp).await?; if let Some(name) = tool_name { - fire_write_hook(config, &name); + // MC-5(MCP-6):仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、 + // create_project 空名等 result.isError=true)仍假触发 df-data-changed。 + if is_success_tool_call(&resp) { + fire_write_hook(config, &name); + } } } Ok(()) } +/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。 +/// +/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败 +/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败 +/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功 +/// +/// MC-5(MCP-6):stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定, +/// 保证两 transport 对「业务失败不触发写回调」语义一致。 +pub(crate) fn is_success_tool_call(resp: &Response) -> bool { + if resp.error.is_some() { + return false; + } + match &resp.result { + Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))), + None => false, + } +} + /// 方法分发 → 构造 Response。 /// /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 @@ -626,4 +648,31 @@ mod tests { *calls.lock().unwrap() ); } + + #[tokio::test] + async fn main_loop_no_write_callback_on_business_failure() { + // MC-5(MCP-6):业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。 + // create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。 + let ctx = test_ctx().await; + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let calls_cb = calls.clone(); + let config = ServerConfig { + on_write_call: Some(Arc::new(move |name| { + calls_cb.lock().unwrap().push(name.to_string()); + })), + ..test_config(false).await + }; + let (mut tx, rx) = tokio::io::duplex(1024); + let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#; + tx.write_all(format!("{line}\n").as_bytes()).await.unwrap(); + drop(tx); + main_loop(rx, tokio::io::sink(), &ctx, &config) + .await + .unwrap(); + assert!( + calls.lock().unwrap().is_empty(), + "业务失败(isError=true)不应触发写回调,实际: {:?}", + *calls.lock().unwrap() + ); + } } diff --git a/crates/df-mcp/src/server_http.rs b/crates/df-mcp/src/server_http.rs index 29e1e94..f18330b 100644 --- a/crates/df-mcp/src/server_http.rs +++ b/crates/df-mcp/src/server_http.rs @@ -162,8 +162,10 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome { let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await; - // ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI) - if resp.error.is_none() { + // ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调 + // (桌面端据此刷新 GUI)。MC-5/MCP-6:业务失败(handler 返 isError=true)不触发, + // 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。 + if crate::server::is_success_tool_call(&resp) { if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) { cb(&name); } diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index e560994..6c89d8f 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -211,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String { .to_owned() } -/// 取可选整数参数(默认值) -fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 { - args.get(key) - .and_then(|v| v.as_i64()) - .map(|i| i as i32) - .unwrap_or(default) -} - /// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。 /// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。 fn pagination(args: &Value) -> (u32, u32) { @@ -360,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; + // MC-6(MCP-4):name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create, + // 防空白项目名进库——required 只保证传参,不保证非空白)。 + let name = name.trim().to_string(); + if name.is_empty() { + return Box::pin(std::future::ready(CallToolResult::error( + "项目名不能为空".to_string(), + ))); + } let description = arg_str_or(&args, "description", ""); let status = arg_str_or(&args, "status", "planning"); medium_audit("create_project", &name); @@ -418,16 +418,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { return r; } - // 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据 + // 部分更新:name/description 缺省回退 existing,避免空默认清空数据 let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); - let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned()); - let status = match ProjectStatus::from_db_str(&status) { - Some(s) => s, - None => return CallToolResult::error( - format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled") - ), - }; + // MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task + // 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。 + // 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。 + let status = existing.status; let now = now_millis(); let rec = ProjectRecord { id: id.clone(), @@ -520,8 +517,15 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")), Err(e) => return err_str(e), } + // MC-6(MCP-2):两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。 + // tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。 if let Err(e) = repo.update_field(&id, "stack", &stack_json).await { - return err_str(e); + tracing::warn!( + tool = "bind_directory", + project_id = %id, + error = %e, + "stack 写回失败(降级不中断,path 已绑定)" + ); } let updated = repo.get_by_id(&id).await.ok().flatten(); json_ok(json!({ "id": id, "project": updated })) @@ -574,6 +578,13 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; + // MC-6(MCP-4):title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。 + let title = title.trim().to_string(); + if title.is_empty() { + return Box::pin(std::future::ready(CallToolResult::error( + "任务标题不能为空".to_string(), + ))); + } let description = arg_str_or(&args, "description", ""); medium_audit("create_task", &format!("{project_id}/{title}")); Box::pin(async move { @@ -784,7 +795,14 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { Err(r) => return Box::pin(std::future::ready(r)), }; let description = arg_str_or(&args, "description", ""); - let priority = arg_int_or(&args, "priority", 1); + // MC-6(MCP-3):priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源, + // 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。 + let priority = match df_storage::crud::normalize_priority( + args.get("priority").and_then(|v| v.as_i64()), + ) { + Ok(p) => p, + Err(e) => return Box::pin(std::future::ready(CallToolResult::error(e))), + }; medium_audit("create_idea", &title); Box::pin(async move { let now = now_millis(); @@ -947,7 +965,17 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { } /// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。 -/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。 +/// 确定性纯函数。 +/// +/// **独立口径声明(MC-6/MCP-5 决策)**:本实现与 `df_ideas::scoring::ScoringEngine` 是两套 +/// 并行算法,维度对齐(均为 0-10 的 feasibility/impact/urgency/overall)但权重/信号词/边界不同。 +/// 保留独立实现而非复用 ScoringEngine 的取舍: +/// - df-mcp 是轻量独立 server,当前不依赖 df-ideas;而 df-ideas 传递依赖 df-ai(reqwest/ +/// eventsource-stream 等重 HTTP 依赖),为消除 ~20 行评分函数把整棵重依赖树拉进 MCP 进程 +/// 投入产出比不划算(编译/二进制/攻击面)。 +/// - 复用还需在 df-mcp 内复制 IdeaRecord→df_ideas::Idea 转换(record_to_idea 同款),再次引入 +/// 双实现;且会改变 MCP 侧既有评分输出值(行为变更)。 +/// 故保持独立口径,在此显式声明;若未来 df-ideas 轻量化或 MCP 确需与 GUI 评分完全一致,再收敛。 fn heuristic_scores(title: &str, description: &str) -> Value { let desc_len = description.chars().count(); // feasibility:描述越详细越可行(评估前已有思考) diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index 13e6a46..c6635d2 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -401,6 +401,61 @@ impl IdeaRepo { .map_err(storage_err)? } + /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。 + /// + /// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段), + /// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`, + /// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。 + pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result { + validate_column_name(field, "ideas")?; + let conn = self.conn.clone(); + let sql = format!( + "UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL", + field + ); + let id = id.to_owned(); + let value = value.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute(&sql, params![value, now, id]) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。 + /// + /// LW-8(BE-CMD-7):promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感, + /// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用 + /// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0, + /// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。 + /// + /// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。 + /// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。 + pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let promoted_to = promoted_to.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \ + WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL", + params![promoted_to, now, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + /// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。 /// /// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换); @@ -1516,6 +1571,51 @@ mod tests { assert!(!repo.restore("i1").await.unwrap()); } + // ── LW-8 立项认领 CAS(claim_promotion)────────────────────────── + // 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败 + // (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。 + + #[tokio::test] + async fn idea_claim_promotion_claims_once_only() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "立项")).await.unwrap(); + // 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库 + assert!(repo.claim_promotion("i1", "p-1").await.unwrap()); + let rec = repo.get_by_id("i1").await.unwrap().unwrap(); + assert_eq!(rec.status.as_str(), "promoted"); + assert_eq!(rec.promoted_to.as_deref(), Some("p-1")); + // 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护) + assert!(!repo.claim_promotion("i1", "p-2").await.unwrap()); + let rec = repo.get_by_id("i1").await.unwrap().unwrap(); + assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项"); + } + + #[tokio::test] + async fn idea_claim_promotion_skips_soft_deleted() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "回收站")).await.unwrap(); + repo.soft_delete("i1").await.unwrap(); + assert!( + !repo.claim_promotion("i1", "p-1").await.unwrap(), + "软删灵感不可立项认领(deleted_at IS NULL 收口)" + ); + } + + // ── LW-6 update_field_active(软删过滤)───────────────────────── + + #[tokio::test] + async fn idea_update_field_active_skips_soft_deleted() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "原标题")).await.unwrap(); + // 未软删:可改 + assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap()); + // 软删后:update_field_active 拒(0 行),字段不被改动 + repo.soft_delete("i1").await.unwrap(); + assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap()); + let rec = repo.get_by_id("i1").await.unwrap().unwrap(); + assert_eq!(rec.title, "新标题", "软删后字段不应被改动"); + } + #[tokio::test] async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() { let repo = setup_idea_repo().await; diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index bb64fb2..6dd49bc 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -43,10 +43,17 @@ pub struct ProjectQuery { pub offset: Option, } +/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。 +/// +/// BE-CMD-3:projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用 +/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义, +/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。 +const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"]; + /// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。 /// -/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name` -/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。 +/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED` +/// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。 /// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。 fn build_order_clause(order_by: Option<&str>) -> Result { let Some(raw) = order_by else { @@ -60,8 +67,13 @@ fn build_order_clause(order_by: Option<&str>) -> Result { let parts: Vec<&str> = raw.split_whitespace().collect(); let col = parts[0]; let dir = parts.get(1).map(|s| s.to_ascii_uppercase()); - // 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。 - validate_column_name(col, "projects")?; + // 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。 + if !PROJECT_ORDER_BY_ALLOWED.contains(&col) { + return Err(df_types::error::Error::Storage(format!( + "非法 order_by 字段名: {col},合法值: {:?}", + PROJECT_ORDER_BY_ALLOWED + ))); + } match dir.as_deref() { None | Some("DESC") => Ok(format!("{col} DESC")), Some("ASC") => Ok(format!("{col} ASC")), @@ -449,6 +461,32 @@ impl ProjectRepo { .map_err(storage_err)? } + /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。 + /// + /// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站项目仍可改字段), + /// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`, + /// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。 + pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result { + validate_column_name(field, "projects")?; + let conn = self.conn.clone(); + let sql = format!( + "UPDATE projects SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL", + field + ); + let id = id.to_owned(); + let value = value.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute(&sql, params![value, now, id]) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + /// 彻底删除:事务级联删全部关联子表→projects(不可恢复) /// /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, diff --git a/crates/df-storage/src/crud/settings.rs b/crates/df-storage/src/crud/settings.rs index 044b924..1fe1401 100644 --- a/crates/df-storage/src/crud/settings.rs +++ b/crates/df-storage/src/crud/settings.rs @@ -125,7 +125,11 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { "promoted_to", "ai_analysis", "scores", "related_ids", "updated_at", ], "projects" => &[ - "id", "name", "description", "status", "idea_id", "path", "stack", "created_at", + // id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写 + // (BE-CMD-3,对标 ideas/tasks 白名单同款防护,防篡改主键/伪造创建时间致子表悬空)。 + // 注:projects 排序用 created_at 不受影响 — build_order_clause 走独立排序白名单 + // (PROJECT_ORDER_BY_ALLOWED,update_field 白名单与排序白名单解耦,同 ideas 模式)。 + "name", "description", "status", "idea_id", "path", "stack", "updated_at", ], "tasks" => &[ diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index ecf9487..cc224ec 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -455,12 +455,20 @@ impl TaskRepo { /// /// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT), /// 返回满足条件的总行数(忽略分页裁剪)。 + /// + /// LW-5(BE-CMD-2):补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query + /// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致 + /// (前端分页 total 与页数据对不上)。 pub async fn count_by_query(&self, query: &TaskQuery) -> Result { let conn = self.conn.clone(); let project_id = query.project_id.clone(); let status = query.status.clone(); let priority = query.priority; + let assignee = query.assignee.clone(); let keyword = query.keyword.clone(); + let queue = query.queue.clone(); + let parent_id = query.parent_id.clone(); + let module_id = query.module_id.clone(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); @@ -480,6 +488,11 @@ impl TaskRepo { where_clauses.push(format!("priority = ?{}", params_vec.len() + 1)); params_vec.push(Box::new(p)); } + // LW-5: assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移) + if let Some(ref a) = assignee { + where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1)); + params_vec.push(Box::new(a.clone())); + } if let Some(ref kw) = keyword { let escaped = kw.replace('%', "\\%").replace('_', "\\_"); let pat = format!("%{escaped}%"); @@ -489,6 +502,19 @@ impl TaskRepo { params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat)); } + // LW-5: queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41) + if let Some(ref q) = queue { + where_clauses.push(format!("queue = ?{}", params_vec.len() + 1)); + params_vec.push(Box::new(q.clone())); + } + if let Some(ref pid) = parent_id { + where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1)); + params_vec.push(Box::new(pid.clone())); + } + if let Some(ref mid) = module_id { + where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1)); + params_vec.push(Box::new(mid.clone())); + } let sql = format!( "SELECT COUNT(*) FROM tasks WHERE {}", @@ -781,11 +807,34 @@ impl TaskRepo { .await .map_err(storage_err)? } -} -// ============================================================ -// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB) -// ============================================================ + /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。 + /// + /// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站任务仍可改字段), + /// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`, + /// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。 + /// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。 + pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result { + validate_column_name(field, "tasks")?; + let conn = self.conn.clone(); + let sql = format!( + "UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL", + field + ); + let id = id.to_owned(); + let value = value.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute(&sql, params![value, now, id]) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } +} #[cfg(test)] mod tests { @@ -1244,4 +1293,69 @@ mod tests { assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改"); assert_eq!(after.updated_at, "1800000000000"); } + + // ============================================================ + // LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页) + // ============================================================ + + #[tokio::test] + async fn count_by_query_matches_list_by_query_dimensions() { + let repo = setup().await; + // 父任务 + 3 子/叶任务,覆盖 queue/assignee/parent_id 三维(module_id 需 FK 另测) + repo.insert(trec("parent", "todo", None)).await.unwrap(); + let mut c1 = trec("c1", "backlog", Some("parent")); + c1.assignee = Some("alice".to_string()); + let mut c2 = trec("c2", "todo", Some("parent")); + c2.assignee = Some("bob".to_string()); + let mut c3 = trec("c3", "active", None); + c3.assignee = Some("alice".to_string()); + repo.insert(c1).await.unwrap(); + repo.insert(c2).await.unwrap(); + repo.insert(c3).await.unwrap(); + + // 单维度:queue=backlog → 1(c1) + let q = TaskQuery { queue: Some("backlog".to_string()), ..Default::default() }; + assert_eq!(repo.count_by_query(&q).await.unwrap(), 1); + // assignee=alice → 2(c1 + c3) + let q = TaskQuery { assignee: Some("alice".to_string()), ..Default::default() }; + assert_eq!(repo.count_by_query(&q).await.unwrap(), 2); + // parent_id=parent → 2(c1 + c2) + let q = TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() }; + assert_eq!(repo.count_by_query(&q).await.unwrap(), 2); + // 组合:queue=backlog AND parent_id=parent → 1(c1) + let q = TaskQuery { + queue: Some("backlog".to_string()), + parent_id: Some("parent".to_string()), + ..Default::default() + }; + assert_eq!(repo.count_by_query(&q).await.unwrap(), 1); + + // 关键契约:count 与 list_by_query 对同一 query 结果数一致(翻页 total 与页数据对齐) + for q in [ + TaskQuery { queue: Some("backlog".to_string()), ..Default::default() }, + TaskQuery { assignee: Some("alice".to_string()), ..Default::default() }, + TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() }, + ] { + let count = repo.count_by_query(&q).await.unwrap(); + let list_len = repo.list_by_query(&q).await.unwrap().len() as i64; + assert_eq!(count, list_len, "count 与 list 维度必须一致,query={q:?}"); + } + } + + // ============================================================ + // LW-6 update_field_active 软删过滤(回收站任务不可改字段) + // ============================================================ + + #[tokio::test] + async fn update_field_active_skips_soft_deleted() { + let repo = setup().await; + repo.insert(trec("t1", "todo", None)).await.unwrap(); + // 未软删:可改 + assert!(repo.update_field_active("t1", "title", "新标题").await.unwrap()); + // 软删后:update_field_active 拒(0 行),字段不被改动 + repo.soft_delete("t1").await.unwrap(); + assert!(!repo.update_field_active("t1", "title", "又改").await.unwrap()); + let after = repo.get_by_id("t1").await.unwrap().unwrap(); + assert_eq!(after.title, "新标题", "软删后字段不应被改动"); + } } diff --git a/docs/todo.md b/docs/todo.md index 194dff4..86f9038 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -641,15 +641,15 @@ graph TD > 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23,P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。 **P1(确定性,优先修)**: -- [ ] **WK-1** Dashboard 活跃项目面板恒为空(后端状态机无 'active',filter status==='active' 永不命中)+ 状态映射只覆盖 planning/active/completed(in_progress 显示 0%「规划中」,DBP-1/2 同根) -- [ ] **WK-2** Knowledge「自动选中下一条」完全失效(快照在 filter 后读,findIndex 恒 -1) -- [ ] **WK-3** Knowledge publish/reject/archive 失败仍显示成功反馈并推进详情(runWithCatch 吞错) -- [ ] **WK-4** Ideas relate/promote/数据变更监听用全量列表冲掉当前筛选视图 -- [ ] **WK-5** Ideas 状态菜单可直接置 promoted 绕过立项(promoted_to=null 死状态,需确认后端校验) -- [ ] **WK-6** 任务列表状态徽章无配色(Tasks.vue 无 status-* 色类,与详情页割裂) -- [ ] **WK-7** Ideas 失败操作整块替换列表 + 删除失败清详情(错误条消费不一致) +- [x] **WK-1** ✅ 已修(2026-08-09 0554cc1):ActiveProjectsPanel 改 status !== completed/cancelled + 状态映射对齐 7 态(in_progress→coding/testing→testing/releasing→release),删 active 键 +- [x] **WK-2** ✅ 已修:Knowledge 操作前快照 index,selectNextCandidate 直接按同位置取后续 +- [x] **WK-3** ✅ 已修:updateStatus/archive 返布尔,失败 toast + 中止推进 +- [x] **WK-4** ✅ 已修:promoteIdea/relateIdeas 去内部 loadIdeas + _activeIdeaQuery + Ideas.vue loadCurrentView() +- [x] **WK-5** ✅ 已修:Ideas/IdeaDetail 状态菜单排除 promoted +- [x] **WK-6** ✅ 已修:7 态 status-* 色类提取全局 components.css(列表+详情+项目统一) +- [x] **WK-7** ✅ 已修:Ideas 改 error-banner,失败不清理选中态,deleteIdea 返布尔 -**P2(体验/数据)**: WK-8 列表快捷菜单状态机过滤+补 cancelled / WK-9 快捷菜单被 group overflow 裁切 / WK-10 并发 loadTasks 无 seq(搜索被自动刷新冲掉) / WK-11 搜索态树形进度失真 / WK-12 分页越界(Ideas/Projects 同) / WK-13 失败操作反馈不一致(advance 静默/priority 换错误态) / WK-14 ProjectCard 工程数 N+1 / WK-15 Ideas 对抗评估渲染无防御(缺字段崩/NaN) / WK-16 Ideas updateIdea 无错误包装静默丢改动 / WK-17 Knowledge 切 Tab 搜索残留 / WK-18 捕捉/删除成功无反馈 +**P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续 **P3(27 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3) @@ -660,10 +660,10 @@ graph TD > 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**(Settings+AuditLog29 / layout+sidebar21 / 后端命令45,P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。 **P1(确定性/安全,优先修)**: -- [ ] **LW-1** ConfirmDialog Enter 误触危险操作(焦点在取消按钮按 Enter → 执行删除,preventDefault 抑制取消,安全隐患) -- [ ] **LW-2** update_project 通用 update_field 裸奔(白名单含 id/created_at/status/path 零校验,可改主键致子表悬空) -- [ ] **LW-3** update_idea 无校验(status 可写任意/绕过 promote_idea 得半立项) -- [ ] **LW-4** update_task 漏 queue 校验(可写非法 queue/queue-status 不一致) +- [x] **LW-1** ✅ 已修(0554cc1):ConfirmDialog Enter 分支加 BUTTON 排除(焦点在取消不再误触删除) +- [x] **LW-2** ✅ 已修(7795fe6):update_project 白名单剔除 id/created_at + status 值校验 + path relocate 同款校验 + stack JSON 校验 +- [x] **LW-3** ✅ 已修(7795fe6):update_idea status 值校验 + 拒绝 status=promoted + related_ids/scores JSON 校验 +- [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验 - [ ] **LW-5** count_tasks 与 list_tasks 过滤维度不一致(count 超算致翻页空页) - [ ] **LW-6** update_field 可更新已软删任务(get_by_id 不过滤 deleted_at) - [ ] **LW-7** evaluate_idea 无条件覆盖 pending_review(打回终态) @@ -681,7 +681,13 @@ graph TD > 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。 **P1**: -- [ ] **MC-1** MCP update_project 直接写 status 绕过状态机(vs update_task 收口,可制造非法跳态) +- [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口) +- [x] **MC-2** 🟡 待定(跨端批 4d):df-project normalize_path 存库 +- [x] **MC-3** 🟡 待定(跨端批 4d):df-tunnel 自动重连 +- [x] **MC-4** 🟡 待定(跨端批 4d):df-relay per-device 鉴权 +- [x] **MC-5** ✅ 已修(7795fe6):is_success_tool_call 按 result.isError 判定,业务失败不触发写回调 +- [x] **MC-6** ✅ 已修(7795fe6):create_idea priority normalize + create_project/task 拒空名 + bind_directory 降级;evaluate 复用 ScoringEngine 未做(独立口径声明,df-mcp 不依赖 df-ideas 避免重 HTTP 依赖树) +- [ ] **MC-7** 🟡 df-ideas 自洽性校验 / df-types Priority from_i32(待后续批) - [ ] **MC-2** df-project normalize_path 无条件 to_lowercase 被 bind_directory 存库(大小写敏感系统路径失效,需确认目标平台) **P2(跨端/一致性)**: diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index 3a996f1..5eeacc9 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -121,11 +121,37 @@ pub async fn update_idea( field: String, value: String, ) -> Result { - state + // BE-CMD-4:status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted + // (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。 + if field == "status" { + if IdeaStatus::from_db_str(value.trim()).is_none() { + return Err(format!( + "非法 status 值 {:?},合法值: draft/pending_review/approved/rejected/promoted/archived", + value + )); + } + if value.trim() == "promoted" { + return Err( + "status 不能直接置为 promoted:立项须走 promote_idea(会创建项目并回写 promoted_to)" + .to_string(), + ); + } + } + // BE-CMD-4(含 BE-CMD-23):related_ids/scores 是 JSON 字段,补合法性校验(防脏 JSON 落库)。 + if field == "related_ids" || field == "scores" { + serde_json::from_str::(&value) + .map_err(|e| format!("{field} 不是合法 JSON: {e}"))?; + } + // LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。 + let updated = state .ideas - .update_field(&id, &field, &value) + .update_field_active(&id, &field, &value) .await - .map_err(err_str) + .map_err(err_str)?; + if !updated { + return Err(format!("灵感 ID {id} 不存在或已删除")); + } + Ok(true) } /// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。 @@ -204,23 +230,21 @@ pub async fn promote_idea( .await .map_err(err_str)?; - // 回写灵感:status=promoted + promoted_to(update_full 单事务覆盖可变字段) - // 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而 - // 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 - // 删除而非真事务(改动最小,工程投入产出比最高)。 - let updated = IdeaRecord { - status: IdeaStatus::Promoted, - promoted_to: Some(project_id.clone()), - updated_at: now, - ..record - }; - if let Err(e) = state.ideas.update_full(&updated).await { - // 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项) - tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}"); + // LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。 + // 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」, + // 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。 + // 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目。 + if !state + .ideas + .claim_promotion(&id, &project_id) + .await + .map_err(err_str)? + { + tracing::warn!("灵感 {id} 已被并发立项,回滚本次新建项目 {project_id}"); if let Err(del_err) = state.projects.soft_delete(&project_id).await { tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}"); } - return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e)); + return Err(format!("灵感 {id} 已立项(并发双击),本次立项已回滚")); } // 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。 @@ -362,6 +386,15 @@ async fn evaluate_one( engine: &df_ideas::adversarial::AdversarialEngine, ) -> Result { let id = record.id.clone(); + // LW-7(BE-CMD-6):终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。 + // promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review, + // 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。 + if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) { + return Err(format!( + "灵感 {id} 已是终态({}),不可再评估", + record.status.as_str() + )); + } let idea = record_to_idea(&record); // 多维评分(0-10,IPC 层 *10 缩放为 0-100) diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 37c0863..a366742 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -351,10 +351,55 @@ pub async fn update_project( field: String, value: String, ) -> Result { - // B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 + // BE-CMD-3:白名单剔除 id/created_at 已下沉 storage(allowed_columns_for,update_field 直拒, + // 防改主键/伪造创建时间致子表悬空),此处补剩余字段的语义校验: + // - status:值合法性(ProjectStatus::from_db_str,防任意值进库静默归 planning) + // - stack:JSON 合法性(防脏 JSON 落库) + // - path:relocate 同款校验(normalize + `..` 段拦截 + is_dir + 防重复绑定) + if field == "status" && ProjectStatus::from_db_str(value.trim()).is_none() { + return Err(format!( + "非法 status 值 {:?},合法值: planning/in_progress/testing/releasing/completed/paused/cancelled", + value + )); + } + if field == "stack" { + serde_json::from_str::(&value) + .map_err(|e| format!("stack 不是合法 JSON: {e}"))?; + } + if field == "path" { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("路径不能为空".to_string()); + } + // `..` 段拦截(防穿越,对齐 create_with_binding / relocate) + if trimmed.split(['\\', '/']).any(|seg| seg == "..") { + return Err(format!("路径不得包含 '..' 段: {}", trimmed)); + } + // 目录必须存在(对齐 create_project / relocate 的 is_dir 校验,防绑空目录) + if !Path::new(trimmed).is_dir() { + return Err(format!("目录不存在: {}", trimmed)); + } + // 防重复绑定(排除自身,对齐 relocate) + if let Some(conflict) = find_binding_conflict(&state, trimmed, Some(&id)).await? { + return Err(format!("目录已被项目「{}」绑定", conflict.name)); + } + // 规范化后落库(relocate 同款 normalize_path,防路径写法差异) + let normalized = normalize_path(trimmed); + let updated = state + .projects + .update_field_active(&id, &field, &normalized) + .await + .map_err(err_str)?; + if !updated { + return Err(format!("项目 ID {id} 不存在或已删除")); + } + state.reload_allowed_dirs().await; + return Ok(true); + } + // LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站项目不可改字段。 let updated = state .projects - .update_field(&id, &field, &value) + .update_field_active(&id, &field, &value) .await .map_err(err_str)?; if !updated { diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 7d19a35..753c986 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -6,6 +6,8 @@ use tauri::State; use df_types::types::{new_id, TaskStatus}; use df_storage::crud::TaskQuery; use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord}; +// LW-9(BE-CMD-8):父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。 +use df_nodes::task_advance_node::recompute_parent_status; use crate::state::AppState; @@ -245,6 +247,19 @@ pub async fn create_task( state: State<'_, AppState>, input: CreateTaskInput, ) -> Result { + // ── BE-CMD-1:空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与 + // update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ── + let title = input.title.trim().to_string(); + if title.is_empty() { + return Err("任务标题不能为空".to_string()); + } + if !(0..=3).contains(&input.priority) { + return Err(format!( + "priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low),收到 {}", + input.priority + )); + } + // ── queue 校验(白名单 + 空串默认 todo)── // 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致) let queue = if input.queue.trim().is_empty() { @@ -296,7 +311,7 @@ pub async fn create_task( let record = TaskRecord { id: new_id(), project_id: input.project_id, - title: input.title, + title, description: input.description, status: TaskStatus::Todo, priority: input.priority, @@ -333,6 +348,18 @@ pub async fn create_task( Some(record.status.as_str()), ) .await; + // LW-9(BE-CMD-8):建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。 + // 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。 + if let Some(pid) = &record.parent_id { + if let Err(e) = recompute_parent_status(&state.tasks, pid).await { + tracing::warn!( + task_id = %record.id, + parent_id = %pid, + error = %e, + "[父聚合] create_task 后重算父 status 失败(不阻断)" + ); + } + } Ok(record) } @@ -412,12 +439,27 @@ pub async fn update_task( } // 空字符串 = None(解除父),合法放行 } + // LW-4(BE-CMD-1):queue 字段校验(白名单 + queue/status 一致性,读当前 status)。 + // update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同): + // 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。 + if field == "queue" { + let q = value.trim(); + validate_queue(q)?; + let current = state + .tasks + .get_by_id(&id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?; + assert_queue_status_consistent(q, current.status.as_str())?; + } // B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 // 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。 // // 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated // 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。 // 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。 + // LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。 let current = state .tasks .get_by_id(&id) @@ -425,12 +467,37 @@ pub async fn update_task( .map_err(err_str)?; let updated = state .tasks - .update_field(&id, &field, &value) + .update_field_active(&id, &field, &value) .await .map_err(err_str)?; if !updated { return Err(format!("任务 ID {id} 不存在或已删除")); } + // LW-9(BE-CMD-8):改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。 + // 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。 + if field == "parent_id" { + if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) { + if let Err(e) = recompute_parent_status(&state.tasks, &old_pid).await { + tracing::warn!( + task_id = %id, + parent_id = %old_pid, + error = %e, + "[父聚合] 解除父后重算旧父 status 失败(不阻断)" + ); + } + } + let new_pid = value.trim(); + if !new_pid.is_empty() { + if let Err(e) = recompute_parent_status(&state.tasks, new_pid).await { + tracing::warn!( + task_id = %id, + parent_id = %new_pid, + error = %e, + "[父聚合] 挂载父后重算新父 status 失败(不阻断)" + ); + } + } + } if let Some(rec) = current { emit_event( &state, @@ -741,6 +808,20 @@ pub async fn move_task_queue( .await; } + // LW-9(BE-CMD-8):跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。 + // 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。 + // 聚合失败仅 warn 不阻断。 + if let Some(pid) = &updated.parent_id { + if let Err(e) = recompute_parent_status(&state.tasks, pid).await { + tracing::warn!( + task_id = %updated.id, + parent_id = %pid, + error = %e, + "[父聚合] move_task_queue 后重算父 status 失败(不阻断)" + ); + } + } + Ok(updated) } diff --git a/src/components/ConfirmDialog.vue b/src/components/ConfirmDialog.vue index 6fd7c10..52a4ea6 100644 --- a/src/components/ConfirmDialog.vue +++ b/src/components/ConfirmDialog.vue @@ -42,6 +42,9 @@ function onKey(e: KeyboardEvent) { // Enter 确认:危险操作(删除)仍走 confirm 分支,父级二次语义由 dangerLabel 区分; // 避免在 input/textarea 内回车误触发(SVG/按钮 mask 场景无输入控件,守卫保留)。 const target = e.target as HTMLElement + // LW-1(安全):焦点在按钮上按 Enter 直接 return —— 否则「焦点在取消按钮按 Enter」 + // 会先触发 cancel 按钮 click(emit false)再被此处 emit true 覆盖,点取消=执行删除。 + if (target && target.tagName === 'BUTTON') return if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) { return } diff --git a/src/components/dashboard/ActiveProjectsPanel.vue b/src/components/dashboard/ActiveProjectsPanel.vue index 2bcf300..ed57752 100644 --- a/src/components/dashboard/ActiveProjectsPanel.vue +++ b/src/components/dashboard/ActiveProjectsPanel.vue @@ -58,16 +58,16 @@ function getProjectTaskCount(projectId: string): number { return store.projectTaskCounts[projectId] ?? 0 } -// 阶段 label key:projectStageInfo 返回的 stage 值(coding/testing/release/planning) +// 阶段 label key:projectStageInfo 返回的 stage 值(coding/testing/release/planning/paused/cancelled) // 直接拼接 i18n key dashboard.stage.,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。 -// 面板名为"活跃项目",只显示 active 状态(DB 实际默认值,ProjectStatus union 历史遗留未含 -// 'active',此处断言绕过;DB 实际无 planning/in_progress 等值产生,详见功能决策记录)。 +// 面板名为"活跃项目",按真实生命周期过滤:排除已完结/已取消(后端状态机无 'active',原 +// filter status==='active' 恒空,见走查 DBP-1)。 // // 问题3:按「最近活跃」排序(last_active_at 优先,回退 updated_at),反映业务活跃 // (任务/灵感/状态推进事件)而非元信息修改时间。取前 6 条(completed 归档项目不混入)。 const displayProjects = computed(() => store.projects - .filter(p => (p.status as string) === 'active') + .filter(p => p.status !== 'completed' && p.status !== 'cancelled') .sort((a, b) => (b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at) ) @@ -114,6 +114,8 @@ const displayProjects = computed(() => .dot-release { background: var(--df-success); } .dot-planning { background: var(--df-info); } .dot-cancelled { background: var(--df-text-dim); } +/* WK-1:paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */ +.dot-paused { background: var(--df-text-dim); } .project-name { font-size: 12px; @@ -135,6 +137,7 @@ const displayProjects = computed(() => .chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); } .chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); } .chip-cancelled { background: rgba(255,255,255,0.06); color: var(--df-text-dim); } +.chip-paused { background: rgba(255,255,255,0.06); color: var(--df-text-dim); } .project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .project-bar { @@ -154,6 +157,7 @@ const displayProjects = computed(() => .fill-release { background: var(--df-success); } .fill-planning { background: var(--df-info); } .fill-cancelled { background: var(--df-text-dim); } +.fill-paused { background: var(--df-text-dim); } .project-pct { font-family: var(--df-font-mono); diff --git a/src/components/ideas/IdeaDetail.vue b/src/components/ideas/IdeaDetail.vue index 7acd580..b5b05d3 100644 --- a/src/components/ideas/IdeaDetail.vue +++ b/src/components/ideas/IdeaDetail.vue @@ -273,7 +273,7 @@
  • props.idea.description, () => void nextTick(measureDesc)) // P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单, // 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。 const statusMenuOpen = ref(false) +// WK-5:状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除, +// 见 Ideas.vue onStatusChange 兜底);statusOptions 仍含 promoted 供 label 映射(已转立项的灵感 badge 正常显示)。 +const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted')) function closeStatusMenu() { statusMenuOpen.value = false } diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue index 39a80ec..2335142 100644 --- a/src/components/layout/AppLayout.vue +++ b/src/components/layout/AppLayout.vue @@ -106,10 +106,12 @@ const resizing = ref(false) // maximized=全宽(flex:1填充) | sidebar=固定宽 const panelStyle = computed(() => { if (aiStore.state.maximized) return { flex: '1', minWidth: '0' } + // LW-11:内联 width 用 min(panelWidth, 100vw) —— 小屏 media query 的 width:100vw 会被内联样式 + // 更高优先级覆盖致失效;min() 让窄视口下宽度钳制到 100vw,media query 的 absolute 定位/层级仍生效 return { - width: panelWidth.value + 'px', - minWidth: panelWidth.value + 'px', - maxWidth: panelWidth.value + 'px', + width: `min(${panelWidth.value}px, 100vw)`, + minWidth: `min(${panelWidth.value}px, 100vw)`, + maxWidth: `min(${panelWidth.value}px, 100vw)`, } }) diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index bee84a1..de19490 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -92,15 +92,16 @@ const appSettings = useAppSettingsStore() // 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字) // 优先级:用户手动设置 > 小窗口自动收缩(≤768px) const COLLAPSE_THRESHOLD = 768 -const userCollapsed = ref(false) // 用户手动设置的收缩状态 +// LW-10:useSetting 响应式读(loadAll 异步填充缓存后 watch 自动同步刷新)—— +// 原 onMounted 用 appSettings.get 同步读早于父级 loadAll 完成,重启后偏好丢失 +const userCollapsed = appSettings.useSetting('df-sidebar-collapsed', false) const isNarrowWindow = ref(false) // 小窗口自动收缩标记 const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value) -// 持久化用户手动收缩状态 +// 持久化用户手动收缩状态(useSetting 写 ref 自动 debounce 落库,无需显式 appSettings.set) function toggleCollapsed() { userCollapsed.value = !userCollapsed.value - void appSettings.set('df-sidebar-collapsed', userCollapsed.value) } // 监听窗口宽度:小窗口自动收缩(但不改变用户偏好) @@ -118,8 +119,7 @@ function handleCollapseKeydown(e: KeyboardEvent) { } onMounted(async () => { - // 恢复用户偏好(默认展开) - userCollapsed.value = appSettings.get('df-sidebar-collapsed', false) + // LW-10:收缩偏好由 useSetting 响应式恢复(loadAll 填充缓存后 watch 自动同步),移除 onMounted 同步读 // 检查当前窗口宽度 checkWindowWidth() window.addEventListener('resize', checkWindowWidth) diff --git a/src/components/project/ProjectCard.vue b/src/components/project/ProjectCard.vue index 181aa5c..e0418cf 100644 --- a/src/components/project/ProjectCard.vue +++ b/src/components/project/ProjectCard.vue @@ -136,6 +136,8 @@ watch(() => props.view, () => nextTick(measureDesc)) .stage-coding { background: var(--df-accent-soft); color: var(--df-accent); } .stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); } .stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); } +/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */ +.stage-dim { background: rgba(158,158,158,0.15); color: #9e9e9e; } .card-desc-wrap { position: relative; diff --git a/src/constants/project.ts b/src/constants/project.ts index 40606a3..3a2d0c9 100644 --- a/src/constants/project.ts +++ b/src/constants/project.ts @@ -23,10 +23,17 @@ export const PROJECT_STATUS_LABELS: Record = { } /** 项目状态 → 卡片 badge 样式 class */ +// WK-1:对齐 7 态真实生命周期(planning/in_progress/testing/releasing/completed/paused/cancelled)。 +// 类名对齐 ProjectCard.vue scoped 的 stage-* 色系(design/coding/testing/release),paused/cancelled 走新增 +// stage-dim(置灰)。'active' 键删除:后端无此状态,老数据兜底走 stage-design(与 projectStageInfo 兜底一致)。 export const PROJECT_STATUS_BADGE_CLASS: Record = { planning: 'stage-design', - active: 'stage-coding', + in_progress: 'stage-coding', + testing: 'stage-testing', + releasing: 'stage-release', completed: 'stage-release', + paused: 'stage-dim', + cancelled: 'stage-dim', } /** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */ @@ -39,10 +46,17 @@ export interface ProjectStageInfo { stage: string } +// WK-1:对齐 7 态。stage 值决定 Dashboard dot/chip/fill 颜色档(planning/coding/testing/release/paused/cancelled), +// 需与 ActiveProjectsPanel.vue 的 dot-*/chip-*/fill-* 类一一对应(paused 为该组件新增 dim 类)。 +// 'active' 键已删,老数据兜底走 projectStageInfo 默认 planning。 export const PROJECT_STAGE_INFO: Record = { planning: { stepIndex: 0, progress: 20, stage: 'planning' }, - active: { stepIndex: 2, progress: 55, stage: 'coding' }, + in_progress: { stepIndex: 2, progress: 55, stage: 'coding' }, + testing: { stepIndex: 3, progress: 80, stage: 'testing' }, + releasing: { stepIndex: 4, progress: 95, stage: 'release' }, completed: { stepIndex: 4, progress: 100, stage: 'release' }, + paused: { stepIndex: 1, progress: 40, stage: 'paused' }, + cancelled: { stepIndex: 0, progress: 0, stage: 'cancelled' }, } export function projectStatusLabel(status: string): string { @@ -113,6 +127,19 @@ export function taskStatusClass(status: string): string { return TASK_STATUS_CLASS[mapLegacyStatus(status)] ?? 'status-todo' } +// WK-8:任务状态机合法流转目标(源状态 → 可达目标态,对齐 TaskDetail.vue ADVANCE_MAP 的 target 集)。 +// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done/cancelled 为终态,无推进目标); +// 新建/手动改状态仍走后端 TaskStatus::is_valid 兜底校验。 +export const TASK_STATUS_TRANSITIONS: Record = { + 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'], + done: [], + cancelled: [], +} + // ── 任务优先级 ── export const PRIORITY_LABELS: Record = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' } diff --git a/src/i18n/en/dashboard.ts b/src/i18n/en/dashboard.ts index e59df8b..9c8bce6 100644 --- a/src/i18n/en/dashboard.ts +++ b/src/i18n/en/dashboard.ts @@ -41,8 +41,11 @@ export default { stage: { planning: 'Planning', coding: 'Coding', + testing: 'Testing', release: 'Release', done: 'Done', + paused: 'Paused', + cancelled: 'Cancelled', }, ideaStatus: { draft: 'Draft', diff --git a/src/i18n/zh-CN/dashboard.ts b/src/i18n/zh-CN/dashboard.ts index c67326f..680a979 100644 --- a/src/i18n/zh-CN/dashboard.ts +++ b/src/i18n/zh-CN/dashboard.ts @@ -41,8 +41,11 @@ export default { stage: { planning: '规划中', coding: '编码中', + testing: '测试中', release: '发布中', done: '已完成', + paused: '已暂停', + cancelled: '已取消', }, ideaStatus: { draft: '草稿', diff --git a/src/router/index.ts b/src/router/index.ts index 529eff5..ac308e7 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -106,6 +106,8 @@ const routes = [ const router = createRouter({ history: createWebHashHistory(), routes, + // LW-12:路由切换回到顶部(消除滚动残留:从长列表页切到短页仍停在原滚动位置) + scrollBehavior: () => ({ top: 0 }), }) export default router diff --git a/src/stores/knowledge.ts b/src/stores/knowledge.ts index 8b6e7c0..41e7e1f 100644 --- a/src/stores/knowledge.ts +++ b/src/stores/knowledge.ts @@ -149,21 +149,26 @@ export function useKnowledgeStore() { return record ?? null } - async function updateStatus(id: string, status: string) { - await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => { + async function updateStatus(id: string, status: string): Promise { + // WK-3:返成功布尔(失败时 runWithCatch 已置 state.error),调用方据此中止推进/报错 + const ok = await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => { await knowledgeApi.updateStatus(id, status) // 从 items 和 candidates 中同步移除 state.items = state.items.filter(k => k.id !== id) state.candidates = state.candidates.filter(k => k.id !== id) + return true }) + return ok === true } - async function archive(id: string) { - await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => { + async function archive(id: string): Promise { + const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => { await knowledgeApi.archive(id) state.items = state.items.filter(k => k.id !== id) state.candidates = state.candidates.filter(k => k.id !== id) + return true }) + return ok === true } // ── 配置 ── diff --git a/src/stores/project.ts b/src/stores/project.ts index 6978825..8e49061 100644 --- a/src/stores/project.ts +++ b/src/stores/project.ts @@ -6,7 +6,7 @@ import { createIdeasStore } from './project/ideas' import { createWorkflowStore } from './project/workflow' import { state, clearError } from './project/state' import { taskApi } from '@/api/task' -import type { DfDataChangedPayload, TaskQuery } from '@/api/types' +import type { DfDataChangedPayload, TaskQuery, IdeaQuery } from '@/api/types' // ── barrel 组合器(零行为变更,纯重构) ── // @@ -41,21 +41,34 @@ function createStore() { // 避免刷新把已选 status 冲掉)。 let _activeTaskProject: string | undefined = undefined let _activeTaskStatus: string | undefined = undefined + // WK-10:Tasks 视图搜索关键字登记(数据变更联动刷新时一并下沉 keyword,避免冲掉搜索结果) + let _activeTaskKeyword: string | undefined = undefined function setActiveTaskProject(projectKey: string | undefined) { _activeTaskProject = projectKey } function setActiveTaskStatus(statusKey: string | undefined) { _activeTaskStatus = statusKey } + function setActiveTaskKeyword(keyword: string | undefined) { + _activeTaskKeyword = keyword + } // 构造当前 task 视图筛选对应的 query(供数据变更 listener 复用同一筛选拉取) function buildActiveTaskQuery(): TaskQuery | undefined { if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发 const query: TaskQuery = {} if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus + if (_activeTaskKeyword) query.keyword = _activeTaskKeyword return query } + // WK-4:Ideas 视图当前查询登记(数据变更联动刷新按当前筛选重载,不再无参 loadIdeas 全量冲掉筛选)。 + // undefined = Ideas 视图未挂载(如 Dashboard)→ 退化为全量刷新,保持面板统计新鲜。 + let _activeIdeaQuery: IdeaQuery | undefined = undefined + function setActiveIdeaQuery(q: IdeaQuery | undefined) { + _activeIdeaQuery = q + } + let _dataChangedUnlisten: (() => void) | null = null async function startDataChangedListener() { @@ -76,7 +89,8 @@ function createStore() { // Dashboard 数字(与 Tasks 视图分页/筛选解耦,不受 q undefined 影响) void loadStats() } else if (entity === 'idea') { - void ideasStore.loadIdeas() + // WK-4:按 Ideas 视图当前 query 重载(未挂载时退化为全量刷新 Dashboard 面板) + void ideasStore.loadIdeas(_activeIdeaQuery) } }) return _dataChangedUnlisten @@ -179,6 +193,10 @@ function createStore() { setActiveTaskProject, // F-260621-02(P1 status 下沉):Tasks 视图登记当前 status 筛选,供 listener 复用同一筛选拉取 setActiveTaskStatus, + // WK-10:Tasks 视图登记当前搜索关键字,供 listener 复用同一筛选拉取 + setActiveTaskKeyword, + // WK-4: Ideas 视图登记当前查询,供 listener 按筛选重载(不冲掉筛选视图) + setActiveIdeaQuery, pendingApproval: computed(() => state.pendingApproval), // computed stats, diff --git a/src/stores/project/ideas.ts b/src/stores/project/ideas.ts index 2e22e77..deb7426 100644 --- a/src/stores/project/ideas.ts +++ b/src/stores/project/ideas.ts @@ -12,8 +12,11 @@ export function createIdeasStore() { * F-260621-02:status/keyword/order_by 下沉后端 WHERE,取代前端 computed filter。 * - 传 `query` → 多条件查询(状态/关键词/排序/分页)。 * - 不传 → 全量(created_at DESC,等价旧行为)。 + * + * WK-7:成功时清 state.error(load 后不残留旧错误),失败由 runWithCatch 置 error。 */ async function loadIdeas(query?: IdeaQuery) { + state.error = null await runWithCatch(state, t('ideas.err.loadFailed'), async () => { state.ideas = await ideaApi.list(query) }) @@ -36,11 +39,14 @@ export function createIdeasStore() { } } - async function deleteIdea(id: string) { - await runWithCatch(state, t('ideas.err.deleteFailed'), async () => { + async function deleteIdea(id: string): Promise { + // WK-7:返成功布尔(失败时 runWithCatch 已置 state.error),调用方据此保留选中态 + const ok = await runWithCatch(state, t('ideas.err.deleteFailed'), async () => { await ideaApi.delete(id) state.ideas = state.ideas.filter(i => i.id !== id) + return true }) + return ok === true } async function evaluateIdea(id: string) { @@ -51,15 +57,16 @@ export function createIdeasStore() { } async function promoteIdea(id: string) { - const res = await ideaApi.promote(id) - await loadIdeas() // 后端已回写 status=promoted/promoted_to,刷新列表 - return res + // WK-4:不再内部全量 loadIdeas(会把 store.ideas 冲成全集、冲掉当前筛选视图)。 + // 由视图(Ideas.vue promoteToProject)操作后按当前 buildIdeaQuery() 主动重载。 + return await ideaApi.promote(id) } async function relateIdeas(subjectId: string, targetIds: string[]) { await runWithCatch(state, t('ideas.err.relateFailed'), async () => { await ideaApi.relateIdeas(subjectId, targetIds) - await loadIdeas() // 后端已原子更新全部受影响灵感,刷新列表保持 store 一致 + // WK-4:后端原子更新受影响灵感,原全量 loadIdeas 会冲掉当前筛选视图; + // 改由视图(Ideas.vue onUpdateRelated)按当前 query 重载。 }) } diff --git a/src/stores/project/tasks.ts b/src/stores/project/tasks.ts index 318ca17..c4566e8 100644 --- a/src/stores/project/tasks.ts +++ b/src/stores/project/tasks.ts @@ -1,11 +1,15 @@ import { taskApi } from '@/api' import type { TaskQuery, ProjectId } from '@/api/types' import { t } from '@/i18n/i18n-helpers' -import { runWithCatch } from '@/composables/useStoreAction' +import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction' import { state } from './state' /** 任务 CRUD 子 store(共享全局 state) */ export function createTasksStore() { + // WK-10:并发 loadTasks 竞态守卫(快速切筛选/搜索防旧响应晚到覆盖新结果, + // 对齐 knowledge store 的 _itemsSeq 模式)。旧请求的失败也不污染当前视图。 + let _tasksSeq = 0 + /** * 加载任务列表。 * @@ -17,8 +21,10 @@ export function createTasksStore() { * status/keyword 下沉后端 WHERE(P1/P2),取代 Tasks.vue 旧的前端内存 filter。 */ async function loadTasks(queryOrProjectId?: TaskQuery | string) { - await runWithCatch(state, t('tasks.err.loadFailed'), async () => { - state.tasks = await taskApi.list(queryOrProjectId) + const seq = ++_tasksSeq + await runWithCatchGuarded(state, t('tasks.err.loadFailed'), () => seq === _tasksSeq, async () => { + const result = await taskApi.list(queryOrProjectId) + if (seq === _tasksSeq) state.tasks = result }) } @@ -51,13 +57,16 @@ export function createTasksStore() { }) } - async function deleteTask(id: string) { - await runWithCatch(state, t('tasks.err.deleteFailed'), async () => { + async function deleteTask(id: string): Promise { + // WK-13:返成功布尔(失败时 runWithCatch 已置 state.error),快捷删除据此 toast 反馈 + const ok = await runWithCatch(state, t('tasks.err.deleteFailed'), async () => { // 后端已级联软删子任务(delete_task 返回 { ok, cascaded }); // 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id) await taskApi.delete(id) state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id) + return true }) + return ok === true } return { diff --git a/src/styles/components.css b/src/styles/components.css index a7d2e93..4c9bf77 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -43,6 +43,7 @@ .status-tag { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); + flex-shrink: 0; white-space: nowrap; } .status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); } .status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); } @@ -50,6 +51,18 @@ .status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); } .status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); } +/* — 任务状态徽章色(7 态):Tasks 列表 + TaskDetail/ProjectDetail 详情共用 — + WK-6:原仅 TaskDetail/ProjectDetail scoped 定义,Tasks.vue 列表拿不到 → 裸文本。 + 提取全局对齐三视图;配色以 TaskDetail.vue(7 态最全)为准,尺寸/圆角统一走上方 .status-tag。 + (TaskDetail 的 scoped 同名类特异度更高会覆盖,但值与全局一致,无视觉差异) */ +.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); } +.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); } +.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); } +.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; } +.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); } + /* — 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享 — 原 IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致 (ProjectDetail 注释自承认"复用 IdeaDetail score-bar-* 样式定义")。 diff --git a/src/views/Ideas.vue b/src/views/Ideas.vue index 87ec445..1e9134a 100644 --- a/src/views/Ideas.vue +++ b/src/views/Ideas.vue @@ -8,6 +8,12 @@ + +
    + {{ store.error }} + +
    +