diff --git a/.blueprint/CONVENTIONS.md b/.blueprint/CONVENTIONS.md index df8735a..54b2ee2 100644 --- a/.blueprint/CONVENTIONS.md +++ b/.blueprint/CONVENTIONS.md @@ -1,6 +1,6 @@ --- -version: 1.4.3 -updated: 2026-04-10 +version: 1.4.4 +updated: 2026-04-11 source: dev-manager-tauri --- @@ -471,6 +471,86 @@ cp -r .claude/skills/splitter ~/.claude/skills/ --- +## Rust 代码可测试性规范 + +> 本规范仅适用于**新增** Rust 函数,存量代码不强制回填。 + +### 强制规则 + +**禁止在业务函数内部调用 `pool().get()`**,改为由调用方传入连接: + +```rust +// ❌ 禁止(无法在测试中注入 in-memory DB) +pub fn my_fn(args: Vec) -> Result, String> { + let conn = crate::db::pool().get()?; + ... +} + +// ✅ 正确(依赖注入,测试可传入 in-memory DB) +pub fn my_fn(conn: &rusqlite::Connection, args: Vec) -> Result, String> { + ... +} + +// Tauri command 层负责获取连接并调用 +#[tauri::command] +pub fn my_command(args: Vec) -> Result, String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + my_fn(&conn, args) +} +``` + +### 测试要求 + +| 函数类型 | 测试要求 | +|---------|---------| +| 纯逻辑函数(无 DB) | 必须附带 `#[cfg(test)]` 测试 | +| 含 DB 的业务函数 | 必须使用依赖注入写法,附带 in-memory DB 测试 | +| Tauri command 薄包装层 | 无需测试(只做连接获取 + 函数调用) | + +### 任务卡 acceptance 模板补充 + +含 Rust 业务逻辑的 M/L 任务卡,acceptance 须包含: + +``` +- [ ] 新增函数使用依赖注入(conn 作为参数) +- [ ] 附带 #[cfg(test)] 测试,覆盖主路径 + 空数据边界 +- [ ] cargo test 通过 +``` + +### 测试写法模板 + +```rust +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + // 建表语句(从 db::migrate 复制对应部分) + conn.execute_batch("CREATE TABLE IF NOT EXISTS ...").unwrap(); + conn + } + + #[test] + fn test_主路径() { + let conn = setup(); + // 插入测试数据 + // 调用函数 + // 断言结果 + } + + #[test] + fn test_空数据返回空集合() { + let conn = setup(); + let result = my_fn(&conn, vec![]); + assert!(result.unwrap().is_empty()); + } +} +``` + +--- + ## 注意事项 - 不要删除已完成的任务卡,它们是项目历史的一部分 diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 57c6ac2..440701f 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -265,6 +265,13 @@ modules: progress: 100 position: [2500, 0] + - id: user-project-todos + name: 项目个人备忘 + area: frontend + status: done + progress: 100 + position: [2000, 150] + - id: flywheel-intelligence name: 飞轮智能成长 area: backend diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 9157db8..e61f57f 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -36,6 +36,17 @@ --- +### 📋 补测试:飞轮存量函数安全网 +- status: todo +- complexity: M +- depends: flywheel-intelligence(阶段二启动前完成,保障数据关键路径) +- files: src-tauri/src/commands/blueprint.rs +- acceptance: `collect_flywheel_notes` 有测试覆盖 win_path 匹配、wsl_path 匹配、项目不存在三种情况;`get_flywheel_stats` 有测试覆盖正常快照解析、空项目列表、iteration 跳变检测;所有新测试用依赖注入 + in-memory DB;`cargo test` 全绿 + +> 背景:阶段一实现时因无测试导致"查错表"bug 上线,靠人工 review 才发现。补测试后同类静默失败 bug 可在写代码时立即暴露。 + +--- + ### 阶段二:usage.json 扩展采集维度 - status: todo - complexity: M diff --git a/.blueprint/modules/user-project-todos.md b/.blueprint/modules/user-project-todos.md new file mode 100644 index 0000000..dff16f6 --- /dev/null +++ b/.blueprint/modules/user-project-todos.md @@ -0,0 +1,19 @@ +# 项目个人备忘 + +用户为每个项目写的自由文本待办清单,纯个人备忘,Claude 不感知、不介入。 +无格式要求,写啥都行,支持勾选完成和删除。 + +## 任务卡 + +### ✅ 数据层:user_project_todos 表 + Rust commands +- status: done +- complexity: S +- files: src-tauri/src/db.rs, src-tauri/src/commands/user_todos.rs, src-tauri/src/lib.rs +- acceptance: 新表迁移成功;get/add/toggle/delete 四个 command 可调用;Rust 函数使用依赖注入(conn 作为参数);cargo test 覆盖主路径和空数据边界 + +### ✅ 前端:备忘清单 UI 组件 +- status: done +- complexity: M +- depends: user-project-todos(数据层完成后) +- files: src/components/dashboard/UserTodoPanel.tsx, src/lib/commands.ts, src/components/dashboard/ProjectCard.tsx +- acceptance: 项目卡片或详情面板可展开备忘清单;支持输入新条目(回车确认);支持勾选完成(视觉划线);支持删除;空状态有引导文案;条目按 sort_order 排列 diff --git a/CLAUDE.md b/CLAUDE.md index e240cba..db91502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ docs/ 部署和更新指南 - 代码重构/简化:/simplify → /code-review - 发现深层问题:/Dev-deep-think - + ## 项目蓝图 本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 468480b..39539d8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "dev-manager-tauri" -version = "0.1.14" +version = "0.1.15" dependencies = [ "axum", "base64 0.22.1", diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 2e63942..bfa4352 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1602,7 +1602,9 @@ pub fn get_flywheel_stats(project_paths: Vec) -> Result Vec { let conn = match crate::db::pool().get() { @@ -1613,13 +1615,18 @@ fn collect_flywheel_notes(project_paths: &[String]) -> Vec { let mut notes: Vec = Vec::new(); for path_str in project_paths { - // 通过 win_path 或 wsl_path 查找 project_id 和 name + // project_notes.project_id 引用 project_workspaces.id(迁移后) + // 同时 JOIN project_profiles 拿项目显示名 let proj: Result<(String, String), _> = conn.query_row( - "SELECT id, name FROM projects WHERE win_path = ?1 OR wsl_path = ?1 LIMIT 1", + "SELECT pw.id, pp.name + FROM project_workspaces pw + JOIN project_profiles pp ON pp.id = pw.profile_id + WHERE pw.win_path = ?1 OR pw.wsl_path = ?1 + LIMIT 1", rusqlite::params![path_str], |row| Ok((row.get(0)?, row.get(1)?)), ); - let (project_id, project_name) = match proj { + let (workspace_id, project_name) = match proj { Ok(v) => v, Err(_) => continue, }; @@ -1634,7 +1641,7 @@ fn collect_flywheel_notes(project_paths: &[String]) -> Vec { }; let rows = stmt.query_map( - rusqlite::params![project_id], + rusqlite::params![workspace_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), ); if let Ok(iter) = rows { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0354187..9035377 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,3 +21,4 @@ pub mod buff; pub mod canvas; pub mod claude_config; pub mod mentor; +pub mod user_todos; diff --git a/src-tauri/src/commands/user_todos.rs b/src-tauri/src/commands/user_todos.rs new file mode 100644 index 0000000..5d2084f --- /dev/null +++ b/src-tauri/src/commands/user_todos.rs @@ -0,0 +1,198 @@ +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserTodo { + pub id: i64, + pub project_id: String, + pub content: String, + pub done: bool, + pub sort_order: i64, + pub created_at: String, +} + +// ── 业务函数(依赖注入,可单元测试)──────────────────────────────────────────── + +pub fn get_todos(conn: &Connection, project_id: &str) -> Vec { + let mut stmt = match conn.prepare( + "SELECT id, project_id, content, done, sort_order, created_at + FROM user_project_todos + WHERE project_id = ?1 + ORDER BY sort_order ASC, id ASC", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + stmt.query_map(params![project_id], |row| { + Ok(UserTodo { + id: row.get(0)?, + project_id: row.get(1)?, + content: row.get(2)?, + done: row.get::<_, i64>(3)? != 0, + sort_order: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map(|iter| iter.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() +} + +pub fn add_todo(conn: &Connection, project_id: &str, content: &str) -> Result { + let content = content.trim(); + if content.is_empty() { + return Err("内容不能为空".to_string()); + } + // sort_order = 当前最大值 + 1 + let max_order: i64 = conn + .query_row( + "SELECT COALESCE(MAX(sort_order), -1) FROM user_project_todos WHERE project_id = ?1", + params![project_id], + |row| row.get(0), + ) + .unwrap_or(-1); + + conn.execute( + "INSERT INTO user_project_todos (project_id, content, sort_order) VALUES (?1, ?2, ?3)", + params![project_id, content, max_order + 1], + ) + .map_err(|e| e.to_string())?; + + let id = conn.last_insert_rowid(); + conn.query_row( + "SELECT id, project_id, content, done, sort_order, created_at + FROM user_project_todos WHERE id = ?1", + params![id], + |row| { + Ok(UserTodo { + id: row.get(0)?, + project_id: row.get(1)?, + content: row.get(2)?, + done: row.get::<_, i64>(3)? != 0, + sort_order: row.get(4)?, + created_at: row.get(5)?, + }) + }, + ) + .map_err(|e| e.to_string()) +} + +pub fn toggle_todo(conn: &Connection, id: i64) -> Result<(), String> { + let rows = conn + .execute( + "UPDATE user_project_todos SET done = CASE WHEN done = 0 THEN 1 ELSE 0 END WHERE id = ?1", + params![id], + ) + .map_err(|e| e.to_string())?; + if rows == 0 { + return Err(format!("todo {} 不存在", id)); + } + Ok(()) +} + +pub fn delete_todo(conn: &Connection, id: i64) -> Result<(), String> { + conn.execute("DELETE FROM user_project_todos WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// ── Tauri command 薄包装层(仅负责获取连接)──────────────────────────────────── + +#[tauri::command] +pub fn get_user_todos(project_id: String) -> Result, String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + Ok(get_todos(&conn, &project_id)) +} + +#[tauri::command] +pub fn add_user_todo(project_id: String, content: String) -> Result { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + add_todo(&conn, &project_id, &content) +} + +#[tauri::command] +pub fn toggle_user_todo(id: i64) -> Result<(), String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + toggle_todo(&conn, id) +} + +#[tauri::command] +pub fn delete_user_todo(id: i64) -> Result<(), String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + delete_todo(&conn, id) +} + +// ── 单元测试 ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE user_project_todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + content TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', 'localtime')) + );", + ) + .unwrap(); + conn + } + + #[test] + fn test_add_and_get() { + let conn = setup(); + add_todo(&conn, "proj-1", "买牛奶").unwrap(); + add_todo(&conn, "proj-1", "写测试").unwrap(); + let todos = get_todos(&conn, "proj-1"); + assert_eq!(todos.len(), 2); + assert_eq!(todos[0].content, "买牛奶"); + assert!(!todos[0].done); + } + + #[test] + fn test_empty_content_rejected() { + let conn = setup(); + assert!(add_todo(&conn, "proj-1", " ").is_err()); + } + + #[test] + fn test_toggle() { + let conn = setup(); + let todo = add_todo(&conn, "proj-1", "测试条目").unwrap(); + toggle_todo(&conn, todo.id).unwrap(); + let todos = get_todos(&conn, "proj-1"); + assert!(todos[0].done); + toggle_todo(&conn, todo.id).unwrap(); + let todos = get_todos(&conn, "proj-1"); + assert!(!todos[0].done); + } + + #[test] + fn test_delete() { + let conn = setup(); + let todo = add_todo(&conn, "proj-1", "待删除").unwrap(); + delete_todo(&conn, todo.id).unwrap(); + assert!(get_todos(&conn, "proj-1").is_empty()); + } + + #[test] + fn test_get_empty_returns_empty() { + let conn = setup(); + assert!(get_todos(&conn, "不存在的项目").is_empty()); + } + + #[test] + fn test_sort_order_increments() { + let conn = setup(); + add_todo(&conn, "proj-1", "第一").unwrap(); + add_todo(&conn, "proj-1", "第二").unwrap(); + let todos = get_todos(&conn, "proj-1"); + assert!(todos[0].sort_order < todos[1].sort_order); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index d95a20e..f410239 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -491,6 +491,18 @@ fn migrate(conn: &rusqlite::Connection) { ); ").expect("create project_notes table"); + // 用户项目个人备忘(自由文本待办,Claude 不感知) + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS user_project_todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + content TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + ").expect("create user_project_todos table"); + // 蓝图 Buff:记录哪些项目施加了 buff conn.execute_batch(" CREATE TABLE IF NOT EXISTS blueprint_buffs ( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e2fe4c..af89cef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -271,6 +271,11 @@ pub fn run() { add_project_note, get_group_mentor_report, get_startup_alerts, + // user todos + commands::user_todos::get_user_todos, + commands::user_todos::add_user_todo, + commands::user_todos::toggle_user_todo, + commands::user_todos::delete_user_todo, // beast status (read-only) get_beast_global_status, ]) diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index fcaacb6..9f4f4f6 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -165,7 +165,7 @@ fn tools_list_result() -> Value { }, { "name": "append_project_note", - "description": "将 Claude 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此。", + "description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。", "inputSchema": { "type": "object", "properties": { @@ -175,7 +175,7 @@ fn tools_list_result() -> Value { }, "content": { "type": "string", - "description": "笔记内容,支持 Markdown。建议包含:核心观点、发现的问题、下一步建议。" + "description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮(预估M轮)\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。" } }, "required": ["project_id", "content"] diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx index ebc2529..cfa1e2e 100644 --- a/src/components/dashboard/ProjectCard.tsx +++ b/src/components/dashboard/ProjectCard.tsx @@ -10,6 +10,7 @@ import { PublishModal } from "./PublishModal"; import { BlueprintModal } from "./BlueprintModal"; import { BlueprintPromptModal } from "./BlueprintPromptModal"; import { MentorPanel } from "../mentor/MentorPanel"; +import { UserTodoPanel } from "./UserTodoPanel"; import { ClaudeConfigModal } from "./ClaudeConfigModal"; import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -69,6 +70,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false); const [showMentor, setShowMentor] = useState(false); const [showClaudeConfig, setShowClaudeConfig] = useState(false); + const [showTodos, setShowTodos] = useState(false); const [showBranchMenu, setShowBranchMenu] = useState(false); const [newBranchName, setNewBranchName] = useState(""); const [creatingBranch, setCreatingBranch] = useState(false); @@ -691,6 +693,13 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="项目导师"> 🎓 + + + ))} + + {/* 已完成的条目折叠在末尾 */} + {done.map((t) => ( +
  • + + {t.content} + +
  • + ))} + + + ); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 0a06a02..975d7a9 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1165,6 +1165,29 @@ export const getProjectNotes = (projectId: string) => export const addProjectNote = (projectId: string, content: string, source: string = "manual") => invoke("add_project_note", { projectId, content, source }); +// ── 用户项目个人备忘 ────────────────────────────────────────────────────────── + +export interface UserTodo { + id: number; + project_id: string; + content: string; + done: boolean; + sort_order: number; + created_at: string; +} + +export const getUserTodos = (projectId: string) => + invoke("get_user_todos", { projectId }); + +export const addUserTodo = (projectId: string, content: string) => + invoke("add_user_todo", { projectId, content }); + +export const toggleUserTodo = (id: number) => + invoke("toggle_user_todo", { id }); + +export const deleteUserTodo = (id: number) => + invoke("delete_user_todo", { id }); + // ── Blueprint Buff ──────────────────────────────────────────────────────────── export interface BuffStatus {