diff --git a/.blueprint/CONVENTIONS.md b/.blueprint/CONVENTIONS.md index c3b7dd7..3c3fa0d 100644 --- a/.blueprint/CONVENTIONS.md +++ b/.blueprint/CONVENTIONS.md @@ -1,5 +1,13 @@ +--- +version: 1.0.0 +updated: 2026-04-03 +source: dev-manager-tauri +--- + # 蓝图更新规则 +> ⚠️ 本文件由 dev-manager-tauri 统一管理,请勿手动修改。规则更新请在 dev-manager-tauri 项目中进行。 + 本文件定义了 `.blueprint/` 目录的维护规范,供 Claude 在对话中遵循。 ## 目录结构 @@ -30,6 +38,7 @@ | `in_progress` | 正在开发 | 🔵 蓝色 | | `planned` | 已规划,需求明确 | 🟡 黄色 | | `concept` | 构思中,信息不完整 | ⚪ 灰色 | +| `blocked` | 执行受阻,需架构师介入 | 🔴 红色 | ## 任务卡格式 @@ -42,6 +51,7 @@ modules/.md 中的每个二级标题(`###`)是一张任务卡: - files: src/path/to/file.tsx, src-tauri/src/commands/xxx.rs - depends: other-module-id - acceptance: 简要描述完成标准 +- blocked_reason: (仅 status: blocked 时填写)执行受阻的具体原因 详细说明(可选)。 ``` @@ -54,6 +64,7 @@ modules/.md 中的每个二级标题(`###`)是一张任务卡: | 🔵 | in_progress | 进行中 | | 📋 | todo | 可派发(信息充足) | | 💭 | concept | 构思中 | +| 🔴 | blocked | 执行受阻 | ### 可派发标准(📋) @@ -133,13 +144,25 @@ Claude 在每次对话中应遵循以下流程: | "开始做 XX" | 模块 status → in_progress | | "为什么选这个方案" | 记录到模块的 `## 决策记录` | -### 阶段 3:实现功能 — 标记完成 +### 阶段 3:实现功能 — 标记完成或上报阻塞 ``` -完成代码 → 更新任务卡前缀为 ✅ → status: done +成功 → 更新任务卡前缀为 ✅ → status: done 所有任务卡完成 → 模块 status: done 更新 manifest.yaml 的 updated 日期 + +失败且可自行解决 → 继续尝试 +失败且无法独立解决 → 标记 🔴 blocked + 填写 blocked_reason → 留给架构师处理 ``` +#### 架构师处理 blocked 任务 +当 Opus 读蓝图发现 🔴 blocked 任务时: +1. 分析 `blocked_reason`,定位根因 +2. 根据情况采取行动: + - **拆**:任务本身拆得不好 → 标记原任务 abandoned → 产出新任务卡 + - **补**:缺少前置依赖 → 创建前置任务卡 → 原任务加 depends + - **调**:架构方向需调整 → 更新模块设计/决策记录 → 重新拆任务卡 +3. 新任务卡就绪后,原 blocked 任务可标记为 abandoned 或更新为 todo + ### 阶段 4:回顾 — 用户在桌面 App 查看 用户打开蓝图可视化: - 看到全景进度 @@ -153,6 +176,8 @@ Claude 应关注这些信号来决定是否更新蓝图: - 用户说"这个要拆一下" → 拆分任务卡 - 用户说"记住这个决策" → 写入决策记录 - 用户没提蓝图但在讨论功能 → 主动提议更新蓝图 +- 执行模型遇到无法解决的问题 → 标记 🔴 blocked + blocked_reason +- Opus 看到 blocked 任务 → 分析原因,拆/补/调后产出新任务卡 ## 注意事项 diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 75d83df..b22d881 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -1,7 +1,8 @@ version: 1 name: Dev Manager Tauri description: Git 多空间开发管理器,集成项目管理、Git 操作、CI/CD、开发工具链管理 -updated: 2026-04-02 +updated: 2026-04-03 + areas: - id: frontend @@ -75,10 +76,17 @@ modules: - id: blueprint-view name: 蓝图可视化 area: frontend - status: in_progress - progress: 10 + status: done + progress: 100 position: [1000, 0] + - id: blueprint-onboarding + name: 蓝图接入引导 + area: frontend + status: planned + progress: 0 + position: [1250, 0] + # ── 后端 ──────────────────────────────────────── - id: git-ops name: Git 操作 @@ -125,9 +133,23 @@ modules: - id: blueprint-reader name: 蓝图文件读取 area: backend + status: done + progress: 100 + position: [1000, 350] + + - id: blueprint-governance + name: 蓝图治理 + area: backend status: planned progress: 0 - position: [1000, 350] + position: [1250, 350] + + - id: blueprint-feedback + name: 蓝图反馈聚合 + area: backend + status: concept + progress: 0 + position: [1250, 500] # ── 基础设施 ──────────────────────────────────── - id: cicd-workflow @@ -207,6 +229,21 @@ edges: - from: blueprint-reader to: blueprint-view type: dependency + - from: blueprint-governance + to: blueprint-onboarding + type: dependency + - from: blueprint-reader + to: blueprint-governance + type: related + - from: blueprint-view + to: blueprint-onboarding + type: dependency + - from: blueprint-governance + to: blueprint-feedback + type: related + - from: blueprint-onboarding + to: blueprint-view + type: related # 基础设施关联 - from: cicd-workflow diff --git a/.blueprint/modules/blueprint-feedback.md b/.blueprint/modules/blueprint-feedback.md new file mode 100644 index 0000000..8962910 --- /dev/null +++ b/.blueprint/modules/blueprint-feedback.md @@ -0,0 +1,29 @@ +--- +id: blueprint-feedback +name: 蓝图反馈聚合 +status: concept +progress: 0 +--- + +## 概述 +建立跨项目蓝图反馈收集和分析机制。各项目 Claude 执行时将遇到的问题、格式缺口、改进建议写入 `.blueprint/FEEDBACK.md`,dev-manager-tauri 扫描聚合后呈现,供 Opus 分析并优化 master CONVENTIONS.md,形成飞轮闭环。 + +## 决策记录 + +- FEEDBACK.md 为纯 Markdown,按条目写入,每条含 type / reason / suggestion 字段 +- 反馈类型:blocked(执行受阻)/ convention_gap(规则不够用)/ format_issue(格式问题)/ improvement(改进建议) +- 聚合视图只读,不提供编辑功能,Claude 才是修改 CONVENTIONS.md 的执行者 + +## 任务卡 + +### 💭 FEEDBACK.md 格式约定 +- status: concept +- notes: 需要在 CONVENTIONS.md 中加入 FEEDBACK.md 的写入规则和格式规范,确保各项目 Claude 写入格式一致 + +### 💭 get_all_blueprint_feedbacks command +- status: concept +- notes: 扫描所有已注册项目的 .blueprint/FEEDBACK.md,解析条目,按 type 分类返回 + +### 💭 跨项目反馈聚合视图 +- status: concept +- notes: 在蓝图界面或设置页展示:高频 blocked 原因、重复出现的 convention_gap、跨项目 suggestion 列表;提供「复制给 Opus 分析」按钮,生成汇总报告 diff --git a/.blueprint/modules/blueprint-governance.md b/.blueprint/modules/blueprint-governance.md new file mode 100644 index 0000000..1be7846 --- /dev/null +++ b/.blueprint/modules/blueprint-governance.md @@ -0,0 +1,48 @@ +--- +id: blueprint-governance +name: 蓝图治理 +status: planned +progress: 0 +--- + +## 概述 +将 dev-manager-tauri 的 `.blueprint/CONVENTIONS.md` 和 CLAUDE.md 蓝图区块作为唯一权威来源,向所有托管项目分发和同步规则,确保跨项目规则一致性。 + +## 决策记录 + +- CONVENTIONS.md 加 YAML frontmatter 版本号,应用以此判断是否需要同步 +- CLAUDE.md 用 `` / `` 标记管理区,应用只替换标记内内容,不动项目自有内容 +- 无 CLAUDE.md 时生成初始文件;有 CLAUDE.md 但无 MANAGED 区时追加到末尾 + +## 任务卡 + +### 📋 CONVENTIONS.md 版本化 +- status: todo +- complexity: S +- files: .blueprint/CONVENTIONS.md +- acceptance: frontmatter 加 version 和 updated 字段,格式为 semver(如 1.0.0) + +### 📋 get_blueprint_status command +- status: todo +- complexity: M +- files: src-tauri/src/commands/blueprint.rs +- acceptance: 接收项目路径,返回四种状态之一:none / rules_outdated / content_stale / synced;判断依据:.blueprint/ 是否存在、CONVENTIONS.md 版本是否匹配 master、CLAUDE.md 是否有 MANAGED 区且版本匹配 + +### 📋 sync_blueprint_rules command +- status: todo +- complexity: M +- files: src-tauri/src/commands/blueprint.rs +- acceptance: 接收项目路径,执行三步:(1) 覆盖写入最新 CONVENTIONS.md;(2) 检测 CLAUDE.md 是否存在及 MANAGED 区状态,按三种情况(无文件/无区块/版本旧)分别处理;(3) 返回操作摘要 + +### 📋 CLAUDE.md 模板定义 +- status: todo +- complexity: S +- files: src-tauri/src/commands/blueprint.rs +- acceptance: 在 Rust 中定义 CLAUDE.md MANAGED 区的标准内容模板,内容涵盖:蓝图飞轮流程、读取 manifest 指令、任务卡派发规则;模板版本与 CONVENTIONS.md 同步 + +### 📋 批量同步所有项目规则 +- status: todo +- complexity: S +- files: src-tauri/src/commands/blueprint.rs, src/components/settings/SettingsPage.tsx +- depends: sync_blueprint_rules command +- acceptance: 设置页提供「同步所有项目蓝图规则」按钮,调用 sync_blueprint_rules 遍历所有已注册项目,返回每个项目的同步结果摘要 diff --git a/.blueprint/modules/blueprint-onboarding.md b/.blueprint/modules/blueprint-onboarding.md new file mode 100644 index 0000000..4bd5be8 --- /dev/null +++ b/.blueprint/modules/blueprint-onboarding.md @@ -0,0 +1,38 @@ +--- +id: blueprint-onboarding +name: 蓝图接入引导 +status: planned +progress: 0 +--- + +## 概述 +为无蓝图或蓝图过期的项目提供接入引导:在项目列表展示蓝图状态徽章,并根据不同状态生成对应的 Claude 提示词,由用户复制后粘贴给 Claude 执行。应用负责探测和准备上下文,Claude 负责理解和创作蓝图内容。 + +## 决策记录 + +- 提示词生成由 Rust 完成(扫描目录结构、读取 git log、拼接模板),前端只做展示 +- 提示词包含完整上下文(项目信息 + 目录结构 + 现有蓝图 + CONVENTIONS 摘要),确保 Claude 无需额外询问 +- 目录扫描限深度 3 层,避免输出过大 + +## 任务卡 + +### 📋 generate_blueprint_prompt command +- status: todo +- complexity: M +- files: src-tauri/src/commands/blueprint.rs +- depends: blueprint-governance +- acceptance: 接收项目路径和提示词类型(init / sync),返回对应提示词字符串;init 类型扫描目录结构 + 读取 README/package.json/Cargo.toml;sync 类型额外读取现有蓝图文件 + 近 20 条 git log + +### 📋 项目列表蓝图状态徽章 +- status: todo +- complexity: M +- files: src/components/dashboard/ProjectCard.tsx +- depends: get_blueprint_status command +- acceptance: 项目卡片显示蓝图状态徽章(🔘无蓝图 / 🟡规则待更新 / 🟠内容待同步 / 🟢已同步);🟡状态点击直接执行同步(无需 Claude);其余状态点击打开操作弹窗 + +### 📋 蓝图操作弹窗 +- status: todo +- complexity: M +- files: src/components/dashboard/BlueprintPromptModal.tsx +- depends: generate_blueprint_prompt command +- acceptance: 弹窗展示生成的提示词(monospace、可选中)、一键复制按钮、说明文案("复制后粘贴给 Claude Opus 执行");🔘状态显示「初始化蓝图」提示词,🟠状态显示「同步描绘」提示词 diff --git a/.blueprint/modules/blueprint-reader.md b/.blueprint/modules/blueprint-reader.md index 44e3f06..96c9d85 100644 --- a/.blueprint/modules/blueprint-reader.md +++ b/.blueprint/modules/blueprint-reader.md @@ -1,8 +1,8 @@ --- id: blueprint-reader name: 蓝图文件读取 -status: planned -progress: 0 +status: done +progress: 100 --- ## 概述 @@ -10,20 +10,26 @@ Rust 后端 tauri command,读取项目目录下的 .blueprint/ 文件并解析 ## 任务卡 -### 📋 读取 manifest.yaml -- status: todo +### ✅ 读取 manifest.yaml +- status: done - complexity: M -- files: src-tauri/src/commands/blueprint.rs (待创建) +- files: src-tauri/src/commands/blueprint.rs - acceptance: 读取并解析 manifest.yaml,返回 areas、modules、edges 结构体 -### 📋 读取 modules/*.md -- status: todo +### ✅ 读取 modules/*.md +- status: done - complexity: M -- files: src-tauri/src/commands/blueprint.rs (待创建) +- files: src-tauri/src/commands/blueprint.rs - acceptance: 解析 markdown frontmatter 和内容,提取任务卡列表(标题、status、complexity、files、acceptance) -### 📋 合并返回完整蓝图数据 -- status: todo +### ✅ 合并返回完整蓝图数据 +- status: done - complexity: S -- files: src-tauri/src/commands/blueprint.rs (待创建) +- files: src-tauri/src/commands/blueprint.rs - acceptance: 一次调用返回完整蓝图(manifest + 所有模块详情 + 任务卡),前端无需多次请求 + +## 决策记录 + +- 使用 serde_yaml 解析 manifest.yaml,自定义 parse_module_md 解析 markdown 而非引入额外依赖 +- 模块 progress 由任务卡完成比例自动计算,无需手动维护 +- blocked_reason 作为任务卡可选字段,支持执行模型上报阻塞信息 diff --git a/.blueprint/modules/blueprint-view.md b/.blueprint/modules/blueprint-view.md index 8b8eba4..c5c108d 100644 --- a/.blueprint/modules/blueprint-view.md +++ b/.blueprint/modules/blueprint-view.md @@ -1,8 +1,8 @@ --- id: blueprint-view name: 蓝图可视化 -status: in_progress -progress: 10 +status: done +progress: 100 --- ## 概述 @@ -12,36 +12,76 @@ progress: 10 ### ✅ .blueprint/ 数据结构设计 - status: done -- files: .blueprint/manifest.yaml, .blueprint/CONVENTIONS.md - -定义 manifest.yaml 格式、模块文件格式、任务卡格式、CONVENTIONS 规则。 - -### 📋 蓝图读取 Command -- status: todo - complexity: M -- files: src-tauri/src/commands/blueprint.rs (待创建) +- files: .blueprint/manifest.yaml, .blueprint/CONVENTIONS.md +- acceptance: 定义 manifest.yaml 格式、模块文件格式、任务卡格式、CONVENTIONS 规则 + +### ✅ 蓝图读取 Command +- status: done +- complexity: M +- files: src-tauri/src/commands/blueprint.rs - acceptance: 实现 get_blueprint tauri command,读取指定项目路径下的 .blueprint/manifest.yaml 和 modules/*.md,解析后返回结构化数据 -### 📋 React Flow 节点图渲染 -- status: todo +### ✅ React Flow 节点图渲染 +- status: done - complexity: L -- files: src/components/dashboard/BlueprintModal.tsx (待创建) +- files: src/components/dashboard/BlueprintModal.tsx - depends: blueprint-reader - acceptance: 将 manifest.yaml 的 modules 渲染为自定义节点(带状态色标、进度条),edges 渲染为连线,areas 渲染为分组背景 -### 📋 模块详情面板 -- status: todo +### ✅ 模块详情面板 +- status: done - complexity: M - files: src/components/dashboard/BlueprintModal.tsx - depends: blueprint-reader -- acceptance: 点击节点后在底部/侧边展示模块描述和任务卡列表,任务卡按状态分组显示 +- acceptance: 点击节点后在侧边展示模块描述、决策记录、任务卡列表,任务卡按状态分组显示 -### 📋 项目卡片蓝图入口 -- status: todo +### ✅ 项目卡片蓝图入口 +- status: done - complexity: S - files: src/components/dashboard/ProjectCard.tsx - acceptance: 项目卡片新增「蓝图」按钮,点击打开 BlueprintModal,检测到无 .blueprint/ 时显示引导提示 -### 💭 统计汇总条 -- status: concept -- notes: 模态框顶部展示项目完成度统计条(已完成 X / 进行中 X / 规划中 X / 构思中 X) +### ✅ 统计汇总条 +- status: done +- complexity: S +- files: src/components/dashboard/BlueprintModal.tsx +- acceptance: 模态框顶部展示项目完成度统计条(已完成/进行中/规划中/构思中/受阻),含总进度条和可派发任务入口 + +### ✅ 任务卡复制派发 +- status: done +- complexity: S +- files: src/components/dashboard/BlueprintModal.tsx +- acceptance: 可派发任务卡(todo + files + acceptance + complexity S/M)显示「复制派发」按钮,一键复制自包含的任务指令 + +### ✅ 下一步汇总面板 +- status: done +- complexity: M +- files: src/components/dashboard/BlueprintModal.tsx +- acceptance: 统计条「可派发」按钮打开侧边面板,聚合所有 blocked/进行中/可派发任务,按优先级排序 + +### ✅ 边高亮与模块聚焦 +- status: done +- complexity: M +- files: src/components/dashboard/BlueprintModal.tsx +- acceptance: hover 时高亮关联边并淡化无关边;点击模块 pin 住高亮,关联模块保持原色,无关模块和区域淡化;重置视图按钮恢复全貌 + +### ✅ blocked 状态支持 +- status: done +- complexity: S +- files: src-tauri/src/commands/blueprint.rs, src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx, .blueprint/CONVENTIONS.md +- acceptance: 任务卡支持 🔴 blocked 状态和 blocked_reason 字段,前端红色卡片展示阻塞原因,下一步面板最顶部聚合受阻任务 + +### ✅ 智能连线路由 +- status: done +- complexity: S +- files: src/components/dashboard/BlueprintModal.tsx +- acceptance: 根据模块相对位置自动选择最短路径 handle,避免连线绕远 + +## 决策记录 + +- 选择 React Flow (@xyflow/react) 而非 D3 或自绘 canvas,因为 React Flow 提供开箱即用的节点拖拽、缩放、连线路由 +- 使用 document-driven 蓝图(.blueprint/ 文件)而非数据库存储,因为 Claude 天然支持文件读写且可版本控制 +- 区域分组用 React Flow 父子节点实现,模块作为子节点自动跟随分组 +- hover 只做边高亮,点击(pin)才做节点淡化,避免 React Flow 节点重建导致的闪烁 +- 飞轮模型分配:Opus 做架构需求和蓝图维护,Sonnet 执行任务卡,.blueprint/ 文件作为模型间共享状态 diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index a26df1f..556fe4c 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -60,6 +60,9 @@ pub struct BlueprintTask { pub depends: Option, pub acceptance: Option, pub notes: Option, + pub blocked_reason: Option, + #[serde(default)] + pub locked: bool, // depends 引用的模块尚未完成 } // ── 完整蓝图返回结构 ───────────────────────────────────────────────────────── @@ -77,8 +80,11 @@ pub struct BlueprintStats { pub in_progress: u32, pub planned: u32, pub concept: u32, + pub blocked: u32, pub total_tasks: u32, pub tasks_done: u32, + pub tasks_blocked: u32, + pub tasks_locked: u32, pub dispatchable: u32, } @@ -117,6 +123,31 @@ pub fn get_blueprint(project_path: String) -> Result, Stri } } + // 依赖检查:构建模块状态表,标记 locked 任务 + let module_status: std::collections::HashMap = manifest + .modules + .iter() + .map(|m| (m.id.clone(), m.status.clone())) + .collect(); + + for module in &mut manifest.modules { + for task in &mut module.tasks { + if task.status == "done" || task.status == "blocked" { + continue; // 已完成或已阻塞的任务不需要检查 + } + if let Some(ref depends) = task.depends.clone() { + let unmet = depends + .split(',') + .map(|s| s.trim()) + .filter(|id| !id.is_empty()) + .any(|id| { + module_status.get(id).map(|s| s.as_str()) != Some("done") + }); + task.locked = unmet; + } + } + } + // 统计 let stats = compute_stats(&manifest); @@ -176,6 +207,8 @@ fn parse_module_md(content: &str) -> ParsedModule { depends: None, acceptance: None, notes: None, + blocked_reason: None, + locked: false, }); continue; } @@ -211,6 +244,8 @@ fn parse_module_md(content: &str) -> ParsedModule { task.acceptance = Some(val.trim().to_string()); } else if let Some(val) = trimmed.strip_prefix("notes: ") { task.notes = Some(val.trim().to_string()); + } else if let Some(val) = trimmed.strip_prefix("blocked_reason: ") { + task.blocked_reason = Some(val.trim().to_string()); } } } @@ -253,9 +288,232 @@ fn parse_task_title(raw: &str) -> (String, &str) { if let Some(rest) = raw.strip_prefix("💭 ") { return ("concept".to_string(), rest); } + if let Some(rest) = raw.strip_prefix("🔴 ") { + return ("blocked".to_string(), rest); + } ("todo".to_string(), raw) } +// ── generate_blueprint_prompt ──────────────────────────────────────────────── + +#[tauri::command] +pub fn generate_blueprint_prompt( + project_path: String, + prompt_type: String, // "init" | "sync" +) -> Result { + let root = Path::new(&project_path); + + // 基础信息 + let project_name = root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let dir_tree = scan_dir_tree(root, 0, 3); + let readme = read_file_short(root, "README.md", 60); + let pkg_json = read_file_short(root, "package.json", 40); + let cargo_toml = read_file_short(root, "Cargo.toml", 40); + + let tech_hint = detect_tech_stack(root); + + match prompt_type.as_str() { + "init" => Ok(build_init_prompt(&project_name, &tech_hint, &dir_tree, &readme, &pkg_json, &cargo_toml)), + "sync" => { + let git_log = get_git_log(root); + let blueprint_summary = read_blueprint_summary(root); + Ok(build_sync_prompt(&project_name, &tech_hint, &dir_tree, &git_log, &blueprint_summary)) + } + _ => Err(format!("未知 prompt_type: {prompt_type},应为 init 或 sync")), + } +} + +fn detect_tech_stack(root: &Path) -> String { + let mut hints = Vec::new(); + if root.join("package.json").exists() { + // 尝试从 package.json 读取框架信息 + if let Ok(content) = std::fs::read_to_string(root.join("package.json")) { + for kw in &["next", "react", "vue", "svelte", "vite", "tauri", "electron"] { + if content.contains(kw) { hints.push(kw.to_string()); } + } + } + } + if root.join("Cargo.toml").exists() { hints.push("Rust".to_string()); } + if root.join("go.mod").exists() { hints.push("Go".to_string()); } + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + hints.push("Python".to_string()); + } + if root.join("pom.xml").exists() { hints.push("Java/Maven".to_string()); } + if hints.is_empty() { "未知".to_string() } else { hints.join(", ") } +} + +fn scan_dir_tree(dir: &Path, depth: usize, max_depth: usize) -> String { + const SKIP: &[&str] = &[ + "node_modules", ".git", "target", "dist", ".next", ".nuxt", + "build", "__pycache__", ".venv", "venv", ".cache", "out", + ".turbo", "coverage", + ]; + let indent = " ".repeat(depth); + let mut lines = Vec::new(); + + let Ok(entries) = std::fs::read_dir(dir) else { return String::new() }; + + let mut sorted: Vec<_> = entries.flatten().collect(); + sorted.sort_by_key(|e| e.file_name()); + + for entry in sorted.iter().take(30) { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') && name_str != ".blueprint" { continue; } + if SKIP.iter().any(|s| *s == name_str.as_ref()) { continue; } + + let path = entry.path(); + if path.is_dir() { + lines.push(format!("{indent}{name_str}/")); + if depth < max_depth { + let sub = scan_dir_tree(&path, depth + 1, max_depth); + if !sub.is_empty() { lines.push(sub); } + } + } else { + lines.push(format!("{indent}{name_str}")); + } + } + + lines.join("\n") +} + +fn read_file_short(root: &Path, filename: &str, max_lines: usize) -> String { + let path = root.join(filename); + if !path.exists() { return String::new(); } + let Ok(content) = std::fs::read_to_string(&path) else { return String::new() }; + let lines: Vec<&str> = content.lines().take(max_lines).collect(); + lines.join("\n") +} + +fn get_git_log(root: &Path) -> String { + let output = std::process::Command::new("git") + .args(["log", "--oneline", "-20"]) + .current_dir(root) + .output(); + match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), + _ => "(无法读取 git log,可能不是 git 仓库)".to_string(), + } +} + +fn read_blueprint_summary(root: &Path) -> String { + let bp_dir = root.join(".blueprint"); + if !bp_dir.exists() { return "(无现有蓝图)".to_string(); } + + let mut parts = Vec::new(); + + // manifest + let manifest_path = bp_dir.join("manifest.yaml"); + if let Ok(content) = std::fs::read_to_string(&manifest_path) { + parts.push(format!("=== manifest.yaml ===\n{content}")); + } + + // modules + let modules_dir = bp_dir.join("modules"); + if let Ok(entries) = std::fs::read_dir(&modules_dir) { + let mut module_files: Vec<_> = entries.flatten().collect(); + module_files.sort_by_key(|e| e.file_name()); + for entry in module_files { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.ends_with(".md") { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + parts.push(format!("=== modules/{name_str} ===\n{content}")); + } + } + } + } + + if parts.is_empty() { "(蓝图目录为空)".to_string() } else { parts.join("\n\n") } +} + +fn build_init_prompt( + project_name: &str, + tech_stack: &str, + dir_tree: &str, + readme: &str, + pkg_json: &str, + cargo_toml: &str, +) -> String { + let mut ctx = Vec::new(); + if !readme.is_empty() { ctx.push(format!("### README.md(前 60 行)\n```\n{readme}\n```")); } + if !pkg_json.is_empty() { ctx.push(format!("### package.json(前 40 行)\n```json\n{pkg_json}\n```")); } + if !cargo_toml.is_empty(){ ctx.push(format!("### Cargo.toml(前 40 行)\n```toml\n{cargo_toml}\n```")); } + let ctx_str = if ctx.is_empty() { "(无可读配置文件)".to_string() } else { ctx.join("\n\n") }; + + format!(r#"你是一个软件架构师。请基于以下项目信息,从零初始化这个项目的 .blueprint/ 蓝图。 + +## 项目信息 +- 名称: {project_name} +- 技术栈: {tech_stack} + +## 目录结构 +``` +{dir_tree} +``` + +## 项目配置文件 +{ctx_str} + +## 蓝图规则 +项目根目录下已有 `.blueprint/CONVENTIONS.md`,请严格遵循其格式规范。 + +## 你的任务 +1. 识别项目的主要功能模块(建议 5-15 个),合理划分 area(前端/后端/基础设施等) +2. 创建 `.blueprint/manifest.yaml`(含 areas、modules、edges) +3. 为每个模块创建 `.blueprint/modules/.md`(含概述 + 任务卡) +4. 已实现的功能标记 ✅ done,未实现的标记 📋 todo 或 💭 concept +5. 完成后更新 manifest.yaml 的 updated 日期为今天 + +请直接输出需要创建的文件内容。 +"#) +} + +fn build_sync_prompt( + project_name: &str, + tech_stack: &str, + dir_tree: &str, + git_log: &str, + blueprint_summary: &str, +) -> String { + format!(r#"你是一个软件架构师。请根据项目最新状态,同步更新这个项目的 .blueprint/ 蓝图,使其准确反映代码现实。 + +## 项目信息 +- 名称: {project_name} +- 技术栈: {tech_stack} + +## 目录结构 +``` +{dir_tree} +``` + +## 近期 Git 提交(最近 20 条) +``` +{git_log} +``` + +## 现有蓝图内容 +{blueprint_summary} + +## 蓝图规则 +项目根目录下已有 `.blueprint/CONVENTIONS.md`,请严格遵循其格式规范。 + +## 你的任务 +1. 对照 git 提交记录,找出已完成但蓝图未标记的功能 → 更新对应任务卡为 ✅ done +2. 发现代码/目录中存在但蓝图缺失的功能模块 → 补充对应模块和任务卡 +3. 将不再存在的功能标记为 abandoned 或删除 +4. 更新模块的 status 和 progress 使其与任务卡状态一致 +5. 更新 manifest.yaml 的 updated 日期为今天 + +请直接输出需要修改的文件内容(只输出有变化的文件)。 +"#) +} + fn compute_stats(manifest: &BlueprintManifest) -> BlueprintStats { let mut stats = BlueprintStats { total_modules: manifest.modules.len() as u32, @@ -263,8 +521,11 @@ fn compute_stats(manifest: &BlueprintManifest) -> BlueprintStats { in_progress: 0, planned: 0, concept: 0, + blocked: 0, total_tasks: 0, tasks_done: 0, + tasks_blocked: 0, + tasks_locked: 0, dispatchable: 0, }; @@ -274,6 +535,7 @@ fn compute_stats(manifest: &BlueprintManifest) -> BlueprintStats { "in_progress" => stats.in_progress += 1, "planned" => stats.planned += 1, "concept" => stats.concept += 1, + "blocked" => stats.blocked += 1, _ => {} } for t in &m.tasks { @@ -281,8 +543,15 @@ fn compute_stats(manifest: &BlueprintManifest) -> BlueprintStats { if t.status == "done" { stats.tasks_done += 1; } - // 可派发:todo + 有 files + 有 acceptance + complexity S 或 M + if t.status == "blocked" { + stats.tasks_blocked += 1; + } + if t.locked { + stats.tasks_locked += 1; + } + // 可派发:todo + 未锁定 + 有 files + 有 acceptance + complexity S 或 M if t.status == "todo" + && !t.locked && t.files.is_some() && t.acceptance.is_some() && matches!( diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx index 1334e26..1630bd4 100644 --- a/src/components/dashboard/BlueprintModal.tsx +++ b/src/components/dashboard/BlueprintModal.tsx @@ -33,6 +33,7 @@ const STATUS_CONFIG: Record = { @@ -40,6 +41,8 @@ const TASK_PREFIX: Record = { in_progress: "🔵", todo: "📋", concept: "💭", + blocked: "🔴", + locked: "🔒", }; // ── 领域分组节点 ───────────────────────────────────────────────────────────── @@ -47,7 +50,7 @@ const TASK_PREFIX: Record = { function AreaNode({ data }: { data: { label: string; color: string } }) { return (
void } }) { +function ModuleNode({ data }: { data: BlueprintModule & { areaColor?: string; dimmed?: boolean; pinned?: boolean; onClick: () => void; onHover?: (id: string | null) => void } }) { const cfg = STATUS_CONFIG[data.status] ?? STATUS_CONFIG.concept; const tasksDone = data.tasks.filter((t) => t.status === "done").length; const tasksTotal = data.tasks.length; @@ -73,18 +76,28 @@ function ModuleNode({ data }: { data: BlueprintModule & { areaColor?: string; on return (
data.onHover?.(data.id)} + onMouseLeave={() => data.onHover?.(null)} className="cursor-pointer rounded-lg border-2 shadow-sm px-3 py-2.5 min-w-[160px] max-w-[200px] transition-all hover:shadow-md hover:scale-[1.02]" style={{ - borderColor: cfg.color, - background: cfg.bg, + borderColor: data.dimmed ? "#D1D5DB" : cfg.color, + background: data.dimmed ? "#F9FAFB" : cfg.bg, borderLeftWidth: 4, - borderLeftColor: data.areaColor ?? cfg.color, + borderLeftColor: data.dimmed ? "#E5E7EB" : (data.areaColor ?? cfg.color), + opacity: data.dimmed ? 0.35 : 1, + boxShadow: data.pinned ? undefined : undefined, + animation: data.pinned ? "ssr-glow 2s linear infinite" : undefined, + transition: "opacity 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s", }} > - - - - + + + + + + + +
@@ -118,11 +131,11 @@ const nodeTypes: NodeTypes = { const NODE_W = 180; const NODE_H = 75; -const GAP_X = 40; -const GAP_Y = 30; -const AREA_PAD_X = 20; -const AREA_PAD_Y = 40; -const AREA_GAP = 50; +const GAP_X = 80; +const GAP_Y = 60; +const AREA_PAD_X = 30; +const AREA_PAD_Y = 50; +const AREA_GAP = 80; const COLS_PER_AREA = 4; function autoLayout( @@ -131,6 +144,8 @@ function autoLayout( ) { const areaNodes: Node[] = []; const moduleNodes: Node[] = []; + // 绝对坐标映射,用于计算边的最佳 handle + const absPositions: Record = {}; let areaOffsetY = 0; for (const area of areas) { @@ -156,85 +171,186 @@ function autoLayout( areaModules.forEach((m, i) => { const col = i % cols; const row = Math.floor(i / cols); + const localX = AREA_PAD_X + col * (NODE_W + GAP_X); + const localY = AREA_PAD_Y + row * (NODE_H + GAP_Y); moduleNodes.push({ id: m.id, type: "module", - position: { - x: AREA_PAD_X + col * (NODE_W + GAP_X), - y: AREA_PAD_Y + row * (NODE_H + GAP_Y), - }, + position: { x: localX, y: localY }, parentId: `area-${area.id}`, extent: "parent" as const, data: { ...m, areaColor: area.color }, }); + absPositions[m.id] = { x: localX, y: areaOffsetY + localY }; }); areaOffsetY += areaH + AREA_GAP; } - return { areaNodes, moduleNodes }; + return { areaNodes, moduleNodes, absPositions }; +} + +/** 根据源/目标的相对位置,选出最短路径的 handle 对 */ +function pickHandles( + srcPos: { x: number; y: number }, + tgtPos: { x: number; y: number }, +): { sourceHandle: string; targetHandle: string } { + const dx = tgtPos.x - srcPos.x; + const dy = tgtPos.y - srcPos.y; + + // 以节点中心为基准,判断主要方向 + if (Math.abs(dx) >= Math.abs(dy)) { + // 水平为主 + if (dx > 0) { + // target 在 source 右边:source 右出 → target 左进 + return { sourceHandle: "right-s", targetHandle: "left-t" }; + } else { + // target 在 source 左边:source 左出 → target 右进 + return { sourceHandle: "left-s", targetHandle: "right-t" }; + } + } else { + // 垂直为主 + if (dy > 0) { + // target 在 source 下方:source 下出 → target 上进 + return { sourceHandle: "bottom-s", targetHandle: "top-t" }; + } else { + // target 在 source 上方:source 上出 → target 下进 + return { sourceHandle: "top-s", targetHandle: "bottom-t" }; + } + } } // ── 主组件 ─────────────────────────────────────────────────────────────────── -type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | null; +type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | null; export function BlueprintModal({ projectName, projectPath, onClose }: Props) { const [sidePanel, setSidePanel] = useState(null); + const [hoveredModuleId, setHoveredModuleId] = useState(null); + const [pinnedModuleId, setPinnedModuleId] = useState(null); - const { data: blueprint, isLoading } = useQuery({ + // 实际高亮的模块:pin 优先,否则用 hover + const activeModuleId = pinnedModuleId ?? hoveredModuleId; + + const { data: blueprint, isLoading, error } = useQuery({ queryKey: ["blueprint", projectPath], queryFn: () => getBlueprint(projectPath), }); const handleNodeClick = useCallback((mod: BlueprintModule) => { setSidePanel({ type: "module", module: mod }); + setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id)); }, []); - const { nodes, edges } = useMemo(() => { - if (!blueprint) return { nodes: [] as Node[], edges: [] as Edge[] }; + const handleNodeHover = useCallback((id: string | null) => { + setHoveredModuleId(id); + }, []); - const { areaNodes, moduleNodes } = autoLayout( - blueprint.manifest.areas, - blueprint.manifest.modules, - ); + // 布局(稳定,只在 blueprint 变化时重建) + const layout = useMemo(() => { + if (!blueprint) return null; + return autoLayout(blueprint.manifest.areas, blueprint.manifest.modules); + }, [blueprint]); - // 注入 onClick - for (const n of moduleNodes) { - const mod = blueprint.manifest.modules.find((m) => m.id === n.id); - if (mod) { - n.data = { ...n.data, onClick: () => handleNodeClick(mod) }; - } + // 关联模块集合(仅 pin 时生效,hover 不触发节点淡化) + const connectedIds = useMemo(() => { + if (!pinnedModuleId || !blueprint) return null; + const ids = new Set([pinnedModuleId]); + for (const e of blueprint.manifest.edges) { + if (e.from === pinnedModuleId) ids.add(e.to); + if (e.to === pinnedModuleId) ids.add(e.from); } + return ids; + }, [pinnedModuleId, blueprint]); - const flowNodes: Node[] = [...areaNodes, ...moduleNodes]; + // nodes 依赖 layout + connectedIds(改 dimmed 标记,不改位置) + const { nodes, absPositions } = useMemo(() => { + if (!blueprint || !layout) return { nodes: [] as Node[], absPositions: {} as Record }; - const flowEdges: Edge[] = blueprint.manifest.edges.map((e, i) => ({ - id: `e-${i}`, - source: e.from, - target: e.to, - type: "smoothstep", - animated: e.edge_type === "dependency", - style: { - stroke: e.edge_type === "dependency" ? "#3B82F6" : "#D1D5DB", - strokeWidth: e.edge_type === "dependency" ? 2 : 1, - strokeDasharray: e.edge_type === "related" ? "6 4" : undefined, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: e.edge_type === "dependency" ? "#3B82F6" : "#D1D5DB", - width: 16, - height: 12, - }, - })); + const { areaNodes, moduleNodes, absPositions: pos } = layout; - return { nodes: flowNodes, edges: flowEdges }; - }, [blueprint, handleNodeClick]); + const flowModuleNodes = moduleNodes.map((n) => { + const mod = blueprint.manifest.modules.find((m) => m.id === n.id); + const dimmed = connectedIds != null && !connectedIds.has(n.id); + const pinned = pinnedModuleId === n.id; + return { + ...n, + data: { ...n.data, onClick: () => handleNodeClick(mod!), onHover: handleNodeHover, dimmed, pinned }, + }; + }); + + // area 节点也淡化 + const flowAreaNodes = areaNodes.map((n) => { + const areaId = n.id.replace("area-", ""); + const hasConnected = connectedIds == null || blueprint.manifest.modules.some( + (m) => m.area === areaId && connectedIds.has(m.id), + ); + return { + ...n, + style: { ...n.style, opacity: hasConnected ? 1 : 0.3, transition: "opacity 0.2s" }, + }; + }); + + return { nodes: [...flowAreaNodes, ...flowModuleNodes] as Node[], absPositions: pos }; + }, [blueprint, layout, connectedIds, pinnedModuleId, handleNodeClick, handleNodeHover]); + + // edges 单独 memo,hover 只重建边的样式 + const edges = useMemo(() => { + if (!blueprint) return [] as Edge[]; + + return blueprint.manifest.edges.map((e, i) => { + const isDep = e.edge_type === "dependency"; + const isRelated = activeModuleId != null && (e.from === activeModuleId || e.to === activeModuleId); + const dimmed = activeModuleId != null && !isRelated; + + // 根据模块相对位置选最短路径 handle + const srcPos = absPositions[e.from]; + const tgtPos = absPositions[e.to]; + const handles = srcPos && tgtPos ? pickHandles(srcPos, tgtPos) : { sourceHandle: "right-s", targetHandle: "left-t" }; + + return { + id: `e-${i}`, + source: e.from, + target: e.to, + sourceHandle: handles.sourceHandle, + targetHandle: handles.targetHandle, + type: "smoothstep", + animated: isRelated && isDep, + style: { + stroke: isRelated + ? (isDep ? "#2563EB" : "#6366F1") + : (isDep ? "#3B82F6" : "#94A3B8"), + strokeWidth: isRelated ? (isDep ? 3.5 : 2.5) : (isDep ? 2.5 : 1.5), + strokeDasharray: isDep ? undefined : "6 4", + opacity: dimmed ? 0.1 : isRelated ? 1 : (isDep ? 0.7 : 0.45), + transition: "opacity 0.2s, stroke-width 0.2s", + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isRelated + ? (isDep ? "#2563EB" : "#6366F1") + : (isDep ? "#3B82F6" : "#94A3B8"), + width: isDep ? 18 : 14, + height: isDep ? 14 : 10, + }, + zIndex: isRelated ? 10 : 0, + }; + }); + }, [blueprint, activeModuleId, absPositions]); const stats = blueprint?.stats; return (
+
{/* 标题栏 */}
@@ -259,10 +375,25 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) { + {stats.tasks_blocked > 0 && ( + + )}
任务 {stats.tasks_done}/{stats.total_tasks} + {stats.total_tasks - stats.tasks_done > 0 && ( + + )} {stats.dispatchable > 0 && ( )} - {/* 总进度条 */} -
+ {/* 总进度条(固定在右侧) */} +
+ {pinnedModuleId && ( + + )} +
加载中...
+ ) : error ? ( +
+
!
+

蓝图解析失败

+
+

+ {error instanceof Error ? error.message : String(error)} +

+
+

+ 请检查 .blueprint/manifest.yaml 格式是否正确 +

+
) : !blueprint ? (
🗺️
@@ -353,13 +506,19 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) { setSidePanel(null)} + onClose={() => { setSidePanel(null); setPinnedModuleId(null); }} + /> + ) : sidePanel.type === "remaining" ? ( + { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }} + onClose={() => { setSidePanel(null); setPinnedModuleId(null); }} /> ) : ( setSidePanel({ type: "module", module: mod })} - onClose={() => setSidePanel(null)} + onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }} + onClose={() => { setSidePanel(null); setPinnedModuleId(null); }} /> )}
@@ -392,6 +551,7 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule const groups: Record = { done: [], in_progress: [], + blocked: [], todo: [], concept: [], }; @@ -405,6 +565,7 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule const groupLabels: Record = { done: "已完成", in_progress: "进行中", + blocked: "受阻", todo: "待开发", concept: "构思中", }; @@ -463,7 +624,7 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule {/* 任务卡列表 */} {mod.tasks.length > 0 && (
- {(["in_progress", "todo", "concept", "done"] as const).map((status) => { + {(["blocked", "in_progress", "todo", "concept", "done"] as const).map((status) => { const tasks = grouped[status]; if (!tasks || tasks.length === 0) return null; return ( @@ -490,9 +651,10 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: string }) { const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); - const prefix = TASK_PREFIX[task.status] ?? ""; + const prefix = task.locked ? "🔒" : (TASK_PREFIX[task.status] ?? ""); const isDispatchable = task.status === "todo" && + !task.locked && task.files && task.acceptance && (task.complexity === "S" || task.complexity === "M"); @@ -520,9 +682,13 @@ function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: stri
setExpanded(!expanded)} className={`rounded-lg border px-3 py-2 cursor-pointer transition-colors ${ - isDispatchable - ? "border-blue-200 bg-blue-50/50 hover:bg-blue-50" - : "border-gray-100 bg-white hover:bg-gray-50" + task.locked + ? "border-gray-200 bg-gray-50/80 hover:bg-gray-100 opacity-60" + : task.status === "blocked" + ? "border-red-200 bg-red-50/50 hover:bg-red-50" + : isDispatchable + ? "border-blue-200 bg-blue-50/50 hover:bg-blue-50" + : "border-gray-100 bg-white hover:bg-gray-50" }`} >
@@ -543,6 +709,20 @@ function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: stri )}
+ {/* locked 依赖提示,始终显示 */} + {task.locked && task.depends && ( +
+ 待解锁,依赖: {task.depends} +
+ )} + + {/* blocked_reason 始终显示,不需要展开 */} + {task.status === "blocked" && task.blocked_reason && ( +
+ 阻塞原因: {task.blocked_reason} +
+ )} + {expanded && (
{task.files && ( @@ -584,17 +764,19 @@ function NextActionsPanel({ !!t.files && !!t.acceptance && (t.complexity === "S" || t.complexity === "M"); - if (dispatchable || t.status === "in_progress") { + if (dispatchable || t.status === "in_progress" || t.status === "blocked" || t.locked) { items.push({ module: mod, task: t, dispatchable }); } } } - // 进行中排前面,可派发排后面 - items.sort((a, b) => { - if (a.task.status === "in_progress" && b.task.status !== "in_progress") return -1; - if (a.task.status !== "in_progress" && b.task.status === "in_progress") return 1; - return 0; - }); + // blocked 最前,进行中次之,可派发再次,locked 排最后 + const priority = (item: typeof items[0]): number => { + if (item.task.status === "blocked") return 0; + if (item.task.status === "in_progress") return 1; + if (item.task.locked) return 3; + return 2; // dispatchable + }; + items.sort((a, b) => priority(a) - priority(b)); return items; }, [modules]); @@ -609,6 +791,26 @@ function NextActionsPanel({

暂无可执行任务

) : (
+ {actionItems.filter((i) => i.task.status === "blocked").length > 0 && ( +
+

受阻 — 需架构师介入

+
+ {actionItems + .filter((i) => i.task.status === "blocked") + .map((item, i) => ( +
+ + +
+ ))} +
+
+ )} {actionItems.filter((i) => i.task.status === "in_progress").length > 0 && (

进行中

@@ -649,6 +851,97 @@ function NextActionsPanel({
)} + {actionItems.filter((i) => i.task.locked).length > 0 && ( +
+

待解锁(依赖未完成)

+
+ {actionItems + .filter((i) => i.task.locked) + .map((item, i) => ( +
+ + +
+ ))} +
+
+ )} +
+ )} +
+ ); +} + +// ── 未完成任务面板 ────────────────────────────────────────────────────────── + +const REMAINING_ORDER: Record = { blocked: 0, in_progress: 1, todo: 2, concept: 3 }; +const REMAINING_LABEL: Record = { blocked: "受阻", in_progress: "进行中", todo: "待开发", concept: "构思中" }; +const REMAINING_COLOR: Record = { blocked: "text-red-500", in_progress: "text-blue-500", todo: "text-amber-500", concept: "text-gray-400" }; + +function RemainingTasksPanel({ + modules, + onSelectModule, + onClose, +}: { + modules: BlueprintModule[]; + onSelectModule: (mod: BlueprintModule) => void; + onClose: () => void; +}) { + const grouped = useMemo(() => { + const groups: Record = {}; + for (const mod of modules) { + for (const t of mod.tasks) { + if (t.status === "done") continue; + const key = REMAINING_ORDER[t.status] !== undefined ? t.status : "concept"; + if (!groups[key]) groups[key] = []; + groups[key].push({ module: mod, task: t }); + } + } + return groups; + }, [modules]); + + const sortedKeys = Object.keys(grouped).sort((a, b) => (REMAINING_ORDER[a] ?? 9) - (REMAINING_ORDER[b] ?? 9)); + const totalRemaining = sortedKeys.reduce((sum, k) => sum + grouped[k].length, 0); + + return ( +
+
+
+

未完成任务

+

共 {totalRemaining} 项

+
+ +
+ + {totalRemaining === 0 ? ( +

所有任务已完成

+ ) : ( +
+ {sortedKeys.map((status) => ( +
+

+ {REMAINING_LABEL[status] ?? status} ({grouped[status].length}) +

+
+ {grouped[status].map((item, i) => ( +
+ + +
+ ))} +
+
+ ))}
)}
diff --git a/src/components/dashboard/GroupResourceCanvas.tsx b/src/components/dashboard/GroupResourceCanvas.tsx index 0a3091d..00b9ab3 100644 --- a/src/components/dashboard/GroupResourceCanvas.tsx +++ b/src/components/dashboard/GroupResourceCanvas.tsx @@ -9,6 +9,7 @@ import { Handle, Position, NodeProps, + EdgeProps, useNodesState, useEdgesState, useReactFlow, @@ -17,6 +18,8 @@ import { XYPosition, MarkerType, NodeResizer, + BaseEdge, + EdgeLabelRenderer, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { Project } from "../../lib/commands"; @@ -74,6 +77,32 @@ function DeleteBtn({ nodeId }: { nodeId: string }) { ); } +// ── 节点复制按钮(简单节点通用)────────────────────────────────── + +function CopyBtn({ nodeId }: { nodeId: string }) { + const { getNode, addNodes } = useReactFlow(); + const copy = (e: React.MouseEvent) => { + e.stopPropagation(); + const node = getNode(nodeId); + if (!node) return; + addNodes([{ + ...node, + id: `${node.type ?? "node"}-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`, + position: { x: node.position.x + 20, y: node.position.y + 20 }, + selected: false, + }]); + }; + return ( + + ); +} + // ── 数据节点 ───────────────────────────────────────────────────── function ProjectNode({ id, data }: NodeProps) { @@ -81,6 +110,7 @@ function ProjectNode({ id, data }: NodeProps) { return (
+
@@ -104,6 +134,7 @@ function GitNode({ id, data }: NodeProps) {
openUrl(url).catch(() => {})}> +
@@ -122,6 +153,7 @@ function EnvNode({ id, data }: NodeProps) {
openUrl(url).catch(() => {})}> +
@@ -138,6 +170,7 @@ function ServerNode({ id, data }: NodeProps) { return (
+
@@ -154,6 +187,7 @@ function FocusNode({ id, data }: NodeProps) { return (
+
@@ -169,7 +203,7 @@ function FocusNode({ id, data }: NodeProps) { function CustomNode({ id, data }: NodeProps) { const { name, color } = data as CustomNodeData; - const { updateNodeData, addNodes, addEdges, getNode } = useReactFlow(); + const { updateNodeData, addNodes, addEdges, getNode, deleteElements } = useReactFlow(); const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(name); const [editColor, setEditColor] = useState(color || DEFAULT_CUSTOM_COLOR); @@ -209,7 +243,8 @@ function CustomNode({ id, data }: NodeProps) { target: childId, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "labeled", + data: { label: "", _smooth: true }, style: { stroke: c, strokeWidth: 1.5 }, markerEnd: { type: MarkerType.ArrowClosed, color: c, width: 12, height: 12 }, }]); @@ -280,23 +315,39 @@ function CustomNode({ id, data }: NodeProps) { > {/* 顶部:接受父节点连线(target) */} {/* 底部:发起子节点连线(source) */} {/* 左右保留隐形 handle 供通用连线使用 */} - {/* 删除 & 编辑按钮 */} - - + {/* hover 时顶部工具栏:编辑 / 复制 / 删除 */} +
+ + + +
@@ -321,6 +372,28 @@ function CustomNode({ id, data }: NodeProps) { function ContainerNode({ id, data, selected }: NodeProps) { const { name, color } = data as CustomNodeData; const { updateNodeData, addNodes, getNodes, deleteElements } = useReactFlow(); + + const copyContainer = (e: React.MouseEvent) => { + e.stopPropagation(); + const allNodes = getNodes(); + const self = allNodes.find((n) => n.id === id); + if (!self) return; + const newContainerId = `container-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`; + const children = allNodes.filter((n) => n.parentId === id); + addNodes([ + { + ...self, + id: newContainerId, + position: { x: self.position.x + 20, y: self.position.y + 20 }, + selected: false, + }, + ...children.map((child, i) => ({ + ...child, + id: `cchild-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 4)}`, + parentId: newContainerId, + })), + ]); + }; const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(name); const c = color || DEFAULT_CUSTOM_COLOR; @@ -349,7 +422,7 @@ function ContainerNode({ id, data, selected }: NodeProps) { return (
)} - {/* 颜色色板(hover 显示) */} + {/* 颜色色板(绝对定位,不占 flex 空间,hover 显示) */} {!editing && ( -
+
{PRESET_COLORS.map((pc) => ( + +
+ )} + {nodes.length > 0 && (
{nodes.some((n) => n.type === "custom") && ( diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 381bcf0..1751e27 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -860,6 +860,8 @@ export interface BlueprintTask { depends?: string; acceptance?: string; notes?: string; + blocked_reason?: string; + locked?: boolean; } export interface BlueprintModule { @@ -892,8 +894,11 @@ export interface BlueprintStats { in_progress: number; planned: number; concept: number; + blocked: number; total_tasks: number; tasks_done: number; + tasks_blocked: number; + tasks_locked: number; dispatchable: number; }