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:
lanrtop 2026-04-11 17:21:47 +09:00
parent d33baeb3f7
commit 6162533c77
15 changed files with 490 additions and 11 deletions

View File

@ -1,6 +1,6 @@
--- ---
version: 1.4.3 version: 1.4.4
updated: 2026-04-10 updated: 2026-04-11
source: dev-manager-tauri 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());
}
}
```
---
## 注意事项 ## 注意事项
- 不要删除已完成的任务卡,它们是项目历史的一部分 - 不要删除已完成的任务卡,它们是项目历史的一部分

View File

@ -265,6 +265,13 @@ modules:
progress: 100 progress: 100
position: [2500, 0] position: [2500, 0]
- id: user-project-todos
name: 项目个人备忘
area: frontend
status: done
progress: 100
position: [2000, 150]
- id: flywheel-intelligence - id: flywheel-intelligence
name: 飞轮智能成长 name: 飞轮智能成长
area: backend area: backend

View File

@ -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 扩展采集维度 ### 阶段二usage.json 扩展采集维度
- status: todo - status: todo
- complexity: M - complexity: M

View 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 排列

View File

@ -42,7 +42,7 @@ docs/ 部署和更新指南
- 代码重构/简化:/simplify → /code-review - 代码重构/简化:/simplify → /code-review
- 发现深层问题:/Dev-deep-think - 发现深层问题:/Dev-deep-think
<!-- BLUEPRINT_MANAGED_START version="1.4.3" --> <!-- BLUEPRINT_MANAGED_START version="1.4.4" -->
## 项目蓝图 ## 项目蓝图
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。

2
src-tauri/Cargo.lock generated
View File

@ -880,7 +880,7 @@ dependencies = [
[[package]] [[package]]
name = "dev-manager-tauri" name = "dev-manager-tauri"
version = "0.1.14" version = "0.1.15"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",

View File

@ -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_notesproject_id = workspace_id取最近 50 条。
/// DB 访问失败时静默返回空列表,不影响主流程。 /// DB 访问失败时静默返回空列表,不影响主流程。
fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> { fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
let conn = match crate::db::pool().get() { 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(); let mut notes: Vec<ProjectNoteEntry> = Vec::new();
for path_str in project_paths { 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( 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], rusqlite::params![path_str],
|row| Ok((row.get(0)?, row.get(1)?)), |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, Ok(v) => v,
Err(_) => continue, Err(_) => continue,
}; };
@ -1634,7 +1641,7 @@ fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
}; };
let rows = stmt.query_map( let rows = stmt.query_map(
rusqlite::params![project_id], rusqlite::params![workspace_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
); );
if let Ok(iter) = rows { if let Ok(iter) = rows {

View File

@ -21,3 +21,4 @@ pub mod buff;
pub mod canvas; pub mod canvas;
pub mod claude_config; pub mod claude_config;
pub mod mentor; pub mod mentor;
pub mod user_todos;

View 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);
}
}

View File

@ -491,6 +491,18 @@ fn migrate(conn: &rusqlite::Connection) {
); );
").expect("create project_notes table"); ").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 // 蓝图 Buff记录哪些项目施加了 buff
conn.execute_batch(" conn.execute_batch("
CREATE TABLE IF NOT EXISTS blueprint_buffs ( CREATE TABLE IF NOT EXISTS blueprint_buffs (

View File

@ -271,6 +271,11 @@ pub fn run() {
add_project_note, add_project_note,
get_group_mentor_report, get_group_mentor_report,
get_startup_alerts, 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) // beast status (read-only)
get_beast_global_status, get_beast_global_status,
]) ])

View File

@ -165,7 +165,7 @@ fn tools_list_result() -> Value {
}, },
{ {
"name": "append_project_note", "name": "append_project_note",
"description": "将 Claude 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此", "description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -175,7 +175,7 @@ fn tools_list_result() -> Value {
}, },
"content": { "content": {
"type": "string", "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"] "required": ["project_id", "content"]

View File

@ -10,6 +10,7 @@ import { PublishModal } from "./PublishModal";
import { BlueprintModal } from "./BlueprintModal"; import { BlueprintModal } from "./BlueprintModal";
import { BlueprintPromptModal } from "./BlueprintPromptModal"; import { BlueprintPromptModal } from "./BlueprintPromptModal";
import { MentorPanel } from "../mentor/MentorPanel"; import { MentorPanel } from "../mentor/MentorPanel";
import { UserTodoPanel } from "./UserTodoPanel";
import { ClaudeConfigModal } from "./ClaudeConfigModal"; import { ClaudeConfigModal } from "./ClaudeConfigModal";
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync"; import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener"; 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 [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
const [showMentor, setShowMentor] = useState(false); const [showMentor, setShowMentor] = useState(false);
const [showClaudeConfig, setShowClaudeConfig] = useState(false); const [showClaudeConfig, setShowClaudeConfig] = useState(false);
const [showTodos, setShowTodos] = useState(false);
const [showBranchMenu, setShowBranchMenu] = useState(false); const [showBranchMenu, setShowBranchMenu] = useState(false);
const [newBranchName, setNewBranchName] = useState(""); const [newBranchName, setNewBranchName] = useState("");
const [creatingBranch, setCreatingBranch] = useState(false); 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="项目导师"> 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>
<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)} <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="项目蓝图"> 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 activity */}
{expanded && ( {expanded && (
<div className="border-t border-gray-50 pt-3"> <div className="border-t border-gray-50 pt-3">

View 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>
);
}

View File

@ -1165,6 +1165,29 @@ export const getProjectNotes = (projectId: string) =>
export const addProjectNote = (projectId: string, content: string, source: string = "manual") => export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
invoke<void>("add_project_note", { projectId, content, source }); 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 ──────────────────────────────────────────────────────────── // ── Blueprint Buff ────────────────────────────────────────────────────────────
export interface BuffStatus { export interface BuffStatus {