feat: 飞轮阶段一 + 项目个人备忘功能
飞轮智能成长阶段一:
- 梦核 prompt 注入跨项目复盘笔记,新增【高频陷阱】分析节
- 修复 collect_flywheel_notes 查错表 bug(projects → project_workspaces)
- MCP append_project_note 描述内嵌【复盘】结构化格式,引导 Claude 写标准笔记
- FlywheelStats 新增 project_notes 字段,支持 win_path/wsl_path 两种路径匹配
项目个人备忘(user_project_todos):
- 新表 + 4 个 Rust command,依赖注入写法,6 个单元测试全绿
- UserTodoPanel 组件,📝 按钮展开,支持新增/勾选/删除
CONVENTIONS v1.4.4:新增 Rust 可测试性规范(依赖注入强制要求)
This commit is contained in:
parent
d33baeb3f7
commit
6162533c77
@ -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<String>) -> Result<Vec<Foo>, String> {
|
||||
let conn = crate::db::pool().get()?;
|
||||
...
|
||||
}
|
||||
|
||||
// ✅ 正确(依赖注入,测试可传入 in-memory DB)
|
||||
pub fn my_fn(conn: &rusqlite::Connection, args: Vec<String>) -> Result<Vec<Foo>, String> {
|
||||
...
|
||||
}
|
||||
|
||||
// Tauri command 层负责获取连接并调用
|
||||
#[tauri::command]
|
||||
pub fn my_command(args: Vec<String>) -> Result<Vec<Foo>, 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());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要删除已完成的任务卡,它们是项目历史的一部分
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
19
.blueprint/modules/user-project-todos.md
Normal file
19
.blueprint/modules/user-project-todos.md
Normal file
@ -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 排列
|
||||
@ -42,7 +42,7 @@ docs/ 部署和更新指南
|
||||
- 代码重构/简化:/simplify → /code-review
|
||||
- 发现深层问题:/Dev-deep-think
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.4.3" -->
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.4.4" -->
|
||||
## 项目蓝图
|
||||
|
||||
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -880,7 +880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dev-manager-tauri"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@ -1602,7 +1602,9 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 SQLite 收集各项目的 Claude 复盘笔记(按 win_path 匹配项目,取最近 50 条)。
|
||||
/// 从 SQLite 收集各项目的 Claude 复盘笔记。
|
||||
/// 通过 project_workspaces.win_path / wsl_path 匹配路径,
|
||||
/// 再关联 project_notes(project_id = workspace_id),取最近 50 条。
|
||||
/// DB 访问失败时静默返回空列表,不影响主流程。
|
||||
fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
|
||||
let conn = match crate::db::pool().get() {
|
||||
@ -1613,13 +1615,18 @@ fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
|
||||
let mut notes: Vec<ProjectNoteEntry> = 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<ProjectNoteEntry> {
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
@ -21,3 +21,4 @@ pub mod buff;
|
||||
pub mod canvas;
|
||||
pub mod claude_config;
|
||||
pub mod mentor;
|
||||
pub mod user_todos;
|
||||
|
||||
198
src-tauri/src/commands/user_todos.rs
Normal file
198
src-tauri/src/commands/user_todos.rs
Normal file
@ -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<UserTodo> {
|
||||
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<UserTodo, String> {
|
||||
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<Vec<UserTodo>, 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<UserTodo, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
])
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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="项目导师">
|
||||
🎓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTodos((v) => !v)}
|
||||
className={`px-2.5 py-1.5 rounded-md text-xs border transition-colors ${showTodos ? "border-amber-300 bg-amber-50 text-amber-600" : "border-gray-200 text-gray-500 hover:bg-gray-50"}`}
|
||||
title="个人备忘"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
<button onClick={() => setShowBlueprint(true)}
|
||||
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="项目蓝图">
|
||||
🗺️
|
||||
@ -795,6 +804,9 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个人备忘 */}
|
||||
{showTodos && <UserTodoPanel projectId={p.id} />}
|
||||
|
||||
{/* Expanded activity */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-50 pt-3">
|
||||
|
||||
104
src/components/dashboard/UserTodoPanel.tsx
Normal file
104
src/components/dashboard/UserTodoPanel.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getUserTodos, addUserTodo, toggleUserTodo, deleteUserTodo } from "../../lib/commands";
|
||||
|
||||
export function UserTodoPanel({ projectId }: { projectId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [input, setInput] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: todos = [] } = useQuery({
|
||||
queryKey: ["user-todos", projectId],
|
||||
queryFn: () => getUserTodos(projectId),
|
||||
});
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: (content: string) => addUserTodo(projectId, content),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["user-todos", projectId] });
|
||||
setInput("");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: (id: number) => toggleUserTodo(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["user-todos", projectId] }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deleteUserTodo(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["user-todos", projectId] }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
addMut.mutate(input.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const pending = todos.filter((t) => !t.done);
|
||||
const done = todos.filter((t) => t.done);
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-gray-50 pt-3">
|
||||
{/* 输入框 */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="记点什么… 回车确认"
|
||||
className="w-full text-xs px-2 py-1.5 rounded border border-gray-200 bg-gray-50
|
||||
placeholder-gray-300 focus:outline-none focus:border-gray-400 mb-2"
|
||||
/>
|
||||
|
||||
{/* 待办列表 */}
|
||||
{pending.length === 0 && done.length === 0 && (
|
||||
<p className="text-xs text-gray-300 text-center py-1">暂无备忘</p>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pending.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2 group">
|
||||
<button
|
||||
onClick={() => toggleMut.mutate(t.id)}
|
||||
className="w-3.5 h-3.5 rounded border border-gray-300 shrink-0
|
||||
hover:border-blue-400 transition-colors"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 flex-1 leading-relaxed">{t.content}</span>
|
||||
<button
|
||||
onClick={() => deleteMut.mutate(t.id)}
|
||||
className="text-gray-200 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 text-xs shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* 已完成的条目折叠在末尾 */}
|
||||
{done.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2 group">
|
||||
<button
|
||||
onClick={() => toggleMut.mutate(t.id)}
|
||||
className="w-3.5 h-3.5 rounded border border-gray-200 bg-gray-100 shrink-0
|
||||
flex items-center justify-center hover:border-gray-400 transition-colors"
|
||||
>
|
||||
<span className="text-gray-400 text-[9px] leading-none">✓</span>
|
||||
</button>
|
||||
<span className="text-xs text-gray-300 line-through flex-1 leading-relaxed">{t.content}</span>
|
||||
<button
|
||||
onClick={() => deleteMut.mutate(t.id)}
|
||||
className="text-gray-200 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 text-xs shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1165,6 +1165,29 @@ export const getProjectNotes = (projectId: string) =>
|
||||
export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
|
||||
invoke<void>("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<UserTodo[]>("get_user_todos", { projectId });
|
||||
|
||||
export const addUserTodo = (projectId: string, content: string) =>
|
||||
invoke<UserTodo>("add_user_todo", { projectId, content });
|
||||
|
||||
export const toggleUserTodo = (id: number) =>
|
||||
invoke<void>("toggle_user_todo", { id });
|
||||
|
||||
export const deleteUserTodo = (id: number) =>
|
||||
invoke<void>("delete_user_todo", { id });
|
||||
|
||||
// ── Blueprint Buff ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BuffStatus {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user