From 005079f762c2ec31dd7e3c99eab6ddc94c670338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 26 Jun 2026 20:42:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E7=81=B5=E6=84=9F?= =?UTF-8?q?=E8=BD=AF=E5=88=A0=E9=99=A4=E5=9B=9E=E6=94=B6=E7=AB=99+?= =?UTF-8?q?=E9=9B=B7=E8=BE=BE=E5=9B=BE=E6=AD=A3=E5=90=8D(=E7=88=B6?= =?UTF-8?q?=E2=91=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IDEA-FIX-01: ideas 软删除(对标 tasks V14)— V28 迁移 deleted_at(幂等 ALTER) + IdeaRepo soft_delete/restore/list_deleted + list_by_query 恒带 deleted_at IS NULL(防回收站泄漏) + delete_idea 改软删 + restore_idea/list_deleted_ideas IPC + IdeaRecord 不加字段(纯 SQL WHERE 过滤) - IDEA-FIX-14: 雷达图 CSS class radar→score-bar(IdeaDetail/ProjectDetail 2 文件 template+style+注释,纯正名零行为变更) - 6 新测试(soft_delete 标记+过滤/幂等/missing, restore 还原/仅已删, list_deleted 排序);df-storage 73 测试过 --- crates/df-storage/src/crud/idea_repo.rs | 174 +++++++++++++++++++++++- crates/df-storage/src/migrations.rs | 21 ++- docs/todo.md | 4 +- src-tauri/src/commands/idea.rs | 16 ++- src-tauri/src/lib.rs | 2 + src/components/ideas/IdeaDetail.vue | 28 ++-- src/views/ProjectDetail.vue | 22 +-- 7 files changed, 230 insertions(+), 37 deletions(-) diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index 161e2de..a0b86f1 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -271,8 +271,10 @@ impl IdeaRepo { tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); - // 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符) - let mut where_clauses: Vec = Vec::new(); + // 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。 + // deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果 + // (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。 + let mut where_clauses: Vec = vec!["deleted_at IS NULL".to_string()]; let mut params_vec: Vec> = Vec::new(); if let Some(s) = &status { @@ -288,11 +290,8 @@ impl IdeaRepo { params_vec.push(Box::new(pat)); } - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!(" WHERE {}", where_clauses.join(" AND ")) - }; + // where_clauses 恒含 deleted_at IS NULL(初始项),永非空,直接 join 拼 WHERE。 + let where_sql = format!(" WHERE {}", where_clauses.join(" AND ")); // 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。 // order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。 @@ -341,6 +340,70 @@ impl IdeaRepo { .await .map_err(storage_err)? } + + /// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删灵感,返回是否命中。 + /// 对标 TaskRepo::soft_delete / ProjectRepo::soft_delete。 + pub async fn soft_delete(&self, id: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ideas SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL", + params![now, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 恢复:清 deleted_at(从回收站还原)。仅作用于已删灵感,返回是否命中。 + /// 对标 TaskRepo::restore / ProjectRepo::restore。 + pub async fn restore(&self, id: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ideas SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL", + params![now, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。 + /// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。 + pub async fn list_deleted(&self) -> Result> { + let conn = self.conn.clone(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare(&format!( + "SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC" + )) + .map_err(storage_err)?; + let rows = stmt + .query_map([], |row| idea_from_row(row)) + .map_err(storage_err)?; + let mut results = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } } impl KnowledgeRepo { @@ -1202,4 +1265,101 @@ mod tests { let rec = repo.get_by_id("k1").await.unwrap().unwrap(); assert_eq!(rec.embedding_status.as_deref(), Some("done")); } + + // ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ---------- + + /// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。 + fn irec(id: &str, title: &str) -> IdeaRecord { + IdeaRecord { + id: id.to_string(), + title: title.to_string(), + description: String::new(), + status: "draft".to_string(), + priority: 1, + score: None, + tags: None, + source: None, + promoted_to: None, + ai_analysis: None, + scores: None, + related_ids: None, + created_at: "1700000000000".to_string(), + updated_at: "1700000000000".to_string(), + } + } + + async fn setup_idea_repo() -> IdeaRepo { + let db = Database::open_in_memory().await.expect("open_in_memory"); + IdeaRepo::new(&db) + } + + #[tokio::test] + async fn idea_soft_delete_marks_and_filters_from_list_by_query() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "保留")).await.unwrap(); + repo.insert(irec("i2", "软删")).await.unwrap(); + + // 软删 i2:返回 true(命中) + assert!(repo.soft_delete("i2").await.unwrap()); + + // list_by_query(空 query = 等价全量未删)应只返回 i1,i2 进回收站被过滤 + let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap(); + let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["i1"], "soft_delete 后 list_by_query 应过滤回收站"); + } + + #[tokio::test] + async fn idea_soft_delete_idempotent_on_deleted() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "t")).await.unwrap(); + assert!(repo.soft_delete("i1").await.unwrap()); + // 已删再删:WHERE deleted_at IS NULL 不命中 → false + assert!(!repo.soft_delete("i1").await.unwrap()); + } + + #[tokio::test] + async fn idea_soft_delete_missing_returns_false() { + let repo = setup_idea_repo().await; + // 不存在的 id → affected=0 + assert!(!repo.soft_delete("ghost").await.unwrap()); + } + + #[tokio::test] + async fn idea_restore_brings_back_to_active() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "t")).await.unwrap(); + repo.soft_delete("i1").await.unwrap(); + // 恢复:返回 true,回到 list_by_query + assert!(repo.restore("i1").await.unwrap()); + let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap(); + let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["i1"], "restore 后灵感应回到活跃列表"); + } + + #[tokio::test] + async fn idea_restore_only_affects_deleted() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "t")).await.unwrap(); + // 未删灵感 restore:WHERE deleted_at IS NOT NULL 不命中 → false + assert!(!repo.restore("i1").await.unwrap()); + } + + #[tokio::test] + async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() { + let repo = setup_idea_repo().await; + repo.insert(irec("i1", "活跃")).await.unwrap(); + repo.insert(irec("i2", "回收1")).await.unwrap(); + repo.insert(irec("i3", "回收2")).await.unwrap(); + + // 软删 i2 / i3(updated_at 在 soft_delete 时被刷新为 now) + repo.soft_delete("i2").await.unwrap(); + // 让 i3 的删除时间晚于 i2,保证 updated_at DESC 顺序确定(i3 在前) + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + repo.soft_delete("i3").await.unwrap(); + + let deleted = repo.list_deleted().await.unwrap(); + let ids: Vec<_> = deleted.iter().map(|r| r.id.as_str()).collect(); + // i1 活跃不出现;i3 删除最晚在前 + assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC"); + } } diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 0eac50b..a5361ca 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -32,7 +32,8 @@ pub fn run(conn: &Connection) -> Result<()> { // V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed, // 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 + // 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。 - let steps: [(i32, fn(&Connection) -> Result<()>); 27] = [ + // V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。 + let steps: [(i32, fn(&Connection) -> Result<()>); 28] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -60,6 +61,7 @@ pub fn run(conn: &Connection) -> Result<()> { (25, migrate_v25), (26, migrate_v26), (27, migrate_v27), + (28, migrate_v28), ]; for (version, migrate_fn) in steps { @@ -679,6 +681,23 @@ fn migrate_v27(conn: &Connection) -> Result<()> { Ok(()) } +/// V28: 幂等补 ideas.deleted_at 列(灵感软删回收站,对标 tasks.deleted_at V14) +/// +/// 删除灵感改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 +/// 与 tasks/projects.soft_delete 同模板:IdeaRecord 不带该字段,纯靠 SQL WHERE +/// deleted_at IS NULL 过滤;关联数据(idea_evaluations 评估历史)不动,FK 仍满足, +/// 灵感数据完整保留待恢复。用 PRAGMA 探测列存在性,缺失才 ALTER +/// (同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17/v18/v19/v20/v23/v24 模式),对新库/老库均安全。 +fn migrate_v28(conn: &Connection) -> Result<()> { + if !column_exists(conn, "ideas", "deleted_at") { + conn.execute("ALTER TABLE ideas ADD COLUMN deleted_at TEXT", [])?; + tracing::info!("v28: 补建 ideas.deleted_at 列(灵感软删回收站)"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [28])?; + tracing::info!("迁移 v28 完成"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 diff --git a/docs/todo.md b/docs/todo.md index 1a80aef..73a63f9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -40,7 +40,7 @@ graph TD P2["父② 知识图谱Phase1
📋待办"]:::todo P3["父③ AI对话体验
📋待办"]:::todo P4["父④ F-09 per-conv
📋待办"]:::todo - P5["父⑤ 灵感剩余
📋待办"]:::todo + P5["父⑤ 灵感模块
🔨⑤.1/①.4✅·⑤.2待办"]:::doing P6["父⑥ Phase2-5
📋待办"]:::todo P7["父⑦ 技术债
📋待办"]:::todo @@ -58,7 +58,7 @@ graph TD | **父②** 知识图谱Phase1 | 📋 待办 | ②.1 V28迁移(queue/parent_id/content_json/task_links) / ②.2 TaskRecord+TaskLinkRepo / ②.3 IPC+move_queue / ②.4 父聚合 / ②.5 AI工具12 | G1(弱) | | **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — | | **父④** F-09 per-conv | 📋 待办 | ④.1 streaming/currentText per-conv | — | -| **父⑤** 灵感剩余 | 📋 待办 | ⑤.1 IDEA-FIX-01 软删除 / ⑤.2 #05-#10 P1 | ⑤.2#07→②.1 | +| **父⑤** 灵感模块 | 🔨 进行中 | ⑤.1 软删除✅(V28+Repo+IPC+6测试) / ①.4 雷达图✅ / ⑤.2 #05-#10 P1待办(#07→父②) | ⑤.2#07→②.1 | | **父⑥** Phase2-5 | 📋 待办 | ⑥.1事件流 / ⑥.2基础设施 / ⑥.3注入 / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 | | **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) | diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index e0ff1a4..20b9281 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -122,10 +122,22 @@ pub async fn update_idea( .map_err(err_str) } -/// 删除灵感 +/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。 #[tauri::command] pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result { - state.ideas.delete(&id).await.map_err(err_str) + state.ideas.soft_delete(&id).await.map_err(err_str) +} + +/// 恢复灵感(从回收站还原,清 deleted_at)。对标 restore_task / restore_project。 +#[tauri::command] +pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result { + state.ideas.restore(&id).await.map_err(err_str) +} + +/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。 +#[tauri::command] +pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result, String> { + state.ideas.list_deleted().await.map_err(err_str) } /// 将灵感晋升为项目 — 复用 df-project 领域逻辑创建项目,回写灵感 status=promoted/promoted_to diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f5416d..06ab7ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -260,6 +260,8 @@ pub fn run() { commands::idea::create_idea, commands::idea::update_idea, commands::idea::delete_idea, + commands::idea::restore_idea, + commands::idea::list_deleted_ideas, commands::idea::evaluate_idea, commands::idea::list_idea_evaluations, commands::idea::promote_idea, diff --git a/src/components/ideas/IdeaDetail.vue b/src/components/ideas/IdeaDetail.vue index f90efea..cd123b2 100644 --- a/src/components/ideas/IdeaDetail.vue +++ b/src/components/ideas/IdeaDetail.vue @@ -162,16 +162,16 @@ - +

{{ $t('ideas.multiScoreTitle') }}

-
-
- {{ dim.name }} -
-
+
+
+ {{ dim.name }} +
+
- {{ dim.score }} + {{ dim.score }}
{{ $t('ideas.noEval') }}
@@ -774,31 +774,31 @@ watch(() => props.idea.id, loadHistory) margin: 6px 0 0; } -/* ===== 雷达图(div 模拟) ===== */ -.radar-chart { +/* ===== 多维评分条(div 模拟) ===== */ +.score-bar-chart { display: flex; flex-direction: column; gap: 10px; } -.radar-row { +.score-bar-row { display: flex; align-items: center; gap: 12px; } -.radar-label { +.score-bar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 72px; text-align: right; } -.radar-bar-track { +.score-bar-track { flex: 1; height: 8px; background: var(--df-border); border-radius: var(--df-radius-xs); overflow: hidden; } -.radar-bar-fill { +.score-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; @@ -806,7 +806,7 @@ watch(() => props.idea.id, loadHistory) .fill-high { background: var(--df-success); } .fill-mid { background: var(--df-warning); } .fill-low { background: var(--df-danger); } -.radar-value { +.score-bar-value { font-size: 12px; font-weight: 500; min-width: 28px; diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index b797890..6f8fafe 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -154,16 +154,16 @@

{{ sourceAiAnalysis.summary }}

-
- {{ dim.name }} -
+
+ {{ dim.name }} +
- {{ dim.score }} + {{ dim.score }}
@@ -634,24 +634,24 @@ onUnmounted(() => { .assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); } .assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); } -/* 多维评分条(复用 IdeaDetail radar-* 样式定义) */ +/* 多维评分条(复用 IdeaDetail score-bar-* 样式定义) */ .source-scores { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } -.radar-row { display: flex; align-items: center; gap: 10px; } -.radar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; } -.radar-bar-track { +.score-bar-row { display: flex; align-items: center; gap: 10px; } +.score-bar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; } +.score-bar-track { flex: 1; height: 8px; background: var(--df-border); border-radius: var(--df-radius-xs); overflow: hidden; } -.radar-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; } +.score-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; } .fill-high { background: var(--df-success); } .fill-mid { background: var(--df-warning); } .fill-low { background: var(--df-danger); } -.radar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); } +.score-bar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }