feat: add copy functionality for nodes and containers in GroupResourceCanvas

- Implemented CopyBtn component to allow copying of nodes with a new unique ID and adjusted position.
- Added copy functionality to ProjectNode, GitNode, EnvNode, ServerNode, FocusNode, CustomNode, and ContainerNode.
- Enhanced ContainerNode to copy itself along with its children nodes.
- Introduced a new LabeledEdge component for editable edges with drag-and-drop support for path adjustment and text labels.
- Added right-click context menu for edges to delete connections.
- Updated data structures to support new properties for tasks in BlueprintTask and BlueprintStats.
This commit is contained in:
lanrtop 2026-04-03 08:42:17 +09:00
parent 0cdaa3678c
commit a2596413c5
11 changed files with 1189 additions and 128 deletions

View File

@ -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/<id>.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/<id>.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 任务 → 分析原因,拆/补/调后产出新任务卡
## 注意事项

View File

@ -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

View File

@ -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 分析」按钮,生成汇总报告

View File

@ -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 用 `<!-- BLUEPRINT_MANAGED_START -->` / `<!-- BLUEPRINT_MANAGED_END -->` 标记管理区,应用只替换标记内内容,不动项目自有内容
- 无 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 遍历所有已注册项目,返回每个项目的同步结果摘要

View File

@ -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.tomlsync 类型额外读取现有蓝图文件 + 近 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 执行");🔘状态显示「初始化蓝图」提示词,🟠状态显示「同步描绘」提示词

View File

@ -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 作为任务卡可选字段,支持执行模型上报阻塞信息

View File

@ -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/ 文件作为模型间共享状态

View File

@ -60,6 +60,9 @@ pub struct BlueprintTask {
pub depends: Option<String>,
pub acceptance: Option<String>,
pub notes: Option<String>,
pub blocked_reason: Option<String>,
#[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<Option<BlueprintData>, Stri
}
}
// 依赖检查:构建模块状态表,标记 locked 任务
let module_status: std::collections::HashMap<String, String> = 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<String, String> {
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` areasmodulesedges
3. `.blueprint/modules/<id>.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!(

View File

@ -33,6 +33,7 @@ const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }
in_progress: { color: "#3B82F6", bg: "#EFF6FF", label: "进行中" },
planned: { color: "#EAB308", bg: "#FEFCE8", label: "已规划" },
concept: { color: "#9CA3AF", bg: "#F9FAFB", label: "构思中" },
blocked: { color: "#EF4444", bg: "#FEF2F2", label: "受阻" },
};
const TASK_PREFIX: Record<string, string> = {
@ -40,6 +41,8 @@ const TASK_PREFIX: Record<string, string> = {
in_progress: "🔵",
todo: "📋",
concept: "💭",
blocked: "🔴",
locked: "🔒",
};
// ── 领域分组节点 ─────────────────────────────────────────────────────────────
@ -47,7 +50,7 @@ const TASK_PREFIX: Record<string, string> = {
function AreaNode({ data }: { data: { label: string; color: string } }) {
return (
<div
className="rounded-xl border-2 border-dashed px-4 pt-2 pb-3 min-w-[200px] min-h-[100px] pointer-events-none"
className="rounded-xl border-2 border-dashed px-4 pt-2 pb-3 w-full h-full pointer-events-none"
style={{
borderColor: data.color + "60",
background: data.color + "08",
@ -65,7 +68,7 @@ function AreaNode({ data }: { data: { label: string; color: string } }) {
// ── 模块节点 ─────────────────────────────────────────────────────────────────
function ModuleNode({ data }: { data: BlueprintModule & { areaColor?: string; onClick: () => 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 (
<div
onClick={data.onClick}
onMouseEnter={() => 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",
}}
>
<Handle type="target" position={Position.Left} className="!w-2 !h-2 !-left-1" style={{ background: cfg.color }} />
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !-right-1" style={{ background: cfg.color }} />
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !-top-1" style={{ background: cfg.color }} />
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !-bottom-1" style={{ background: cfg.color }} />
<Handle id="left-t" type="target" position={Position.Left} className="!w-2 !h-2 !-left-1" style={{ background: cfg.color }} />
<Handle id="left-s" type="source" position={Position.Left} className="!w-2 !h-2 !-left-1" style={{ background: cfg.color }} />
<Handle id="right-t" type="target" position={Position.Right} className="!w-2 !h-2 !-right-1" style={{ background: cfg.color }} />
<Handle id="right-s" type="source" position={Position.Right} className="!w-2 !h-2 !-right-1" style={{ background: cfg.color }} />
<Handle id="top-t" type="target" position={Position.Top} className="!w-2 !h-2 !-top-1" style={{ background: cfg.color }} />
<Handle id="top-s" type="source" position={Position.Top} className="!w-2 !h-2 !-top-1" style={{ background: cfg.color }} />
<Handle id="bottom-t" type="target" position={Position.Bottom} className="!w-2 !h-2 !-bottom-1" style={{ background: cfg.color }} />
<Handle id="bottom-s" type="source" position={Position.Bottom} className="!w-2 !h-2 !-bottom-1" style={{ background: cfg.color }} />
<div className="flex items-center gap-1.5 mb-1">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: cfg.color }} />
@ -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<string, { x: number; y: number }> = {};
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<SidePanel>(null);
const [hoveredModuleId, setHoveredModuleId] = useState<string | null>(null);
const [pinnedModuleId, setPinnedModuleId] = useState<string | null>(null);
const { data: blueprint, isLoading } = useQuery<BlueprintData | null>({
// 实际高亮的模块pin 优先,否则用 hover
const activeModuleId = pinnedModuleId ?? hoveredModuleId;
const { data: blueprint, isLoading, error } = useQuery<BlueprintData | null>({
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<string>([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<string, { x: number; y: number }> };
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 单独 memohover 只重建边的样式
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 (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<style>{`
@keyframes ssr-glow {
0% { box-shadow: 0 0 0 2px #FFD700, 0 0 12px #FFD700, 0 0 30px #FFD70060; }
25% { box-shadow: 0 0 0 2px #A855F7, 0 0 12px #A855F7, 0 0 30px #A855F760; }
50% { box-shadow: 0 0 0 2px #06B6D4, 0 0 12px #06B6D4, 0 0 30px #06B6D460; }
75% { box-shadow: 0 0 0 2px #F43F5E, 0 0 12px #F43F5E, 0 0 30px #F43F5E60; }
100% { box-shadow: 0 0 0 2px #FFD700, 0 0 12px #FFD700, 0 0 30px #FFD70060; }
}
`}</style>
<div className="bg-white rounded-xl shadow-2xl w-[95vw] max-w-[1200px] h-[85vh] flex flex-col">
{/* 标题栏 */}
<div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between shrink-0">
@ -259,10 +375,25 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
<StatBadge color="#3B82F6" label="进行中" count={stats.in_progress} />
<StatBadge color="#EAB308" label="已规划" count={stats.planned} />
<StatBadge color="#9CA3AF" label="构思中" count={stats.concept} />
{stats.tasks_blocked > 0 && (
<StatBadge color="#EF4444" label="受阻" count={stats.tasks_blocked} />
)}
<div className="h-4 w-px bg-gray-200" />
<span className="text-xs text-gray-500">
{stats.tasks_done}/{stats.total_tasks}
</span>
{stats.total_tasks - stats.tasks_done > 0 && (
<button
onClick={() => setSidePanel((p) => p?.type === "remaining" ? null : { type: "remaining" })}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
sidePanel?.type === "remaining"
? "text-white bg-amber-500"
: "text-amber-600 bg-amber-50 hover:bg-amber-100"
}`}
>
{stats.total_tasks - stats.tasks_done}
</button>
)}
{stats.dispatchable > 0 && (
<button
onClick={() => setSidePanel((p) => p?.type === "next" ? null : { type: "next" })}
@ -275,8 +406,17 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
📋 {stats.dispatchable}
</button>
)}
{/* 总进度条 */}
<div className="flex-1 flex items-center gap-2 ml-2 min-w-[120px]">
{/* 总进度条(固定在右侧) */}
<div className="flex-1" />
{pinnedModuleId && (
<button
onClick={() => { setPinnedModuleId(null); setSidePanel(null); }}
className="text-xs px-2 py-0.5 rounded-full text-gray-500 bg-gray-100 hover:bg-gray-200 transition-colors shrink-0"
>
</button>
)}
<div className="flex items-center gap-2 min-w-[120px] w-[180px] shrink-0">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
@ -320,6 +460,19 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
...
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full gap-3 px-8">
<div className="w-16 h-16 rounded-2xl bg-red-50 flex items-center justify-center text-2xl">!</div>
<p className="text-gray-600 text-sm font-medium"></p>
<div className="max-w-md w-full px-4 py-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600 font-mono break-all select-text whitespace-pre-wrap">
{error instanceof Error ? error.message : String(error)}
</p>
</div>
<p className="text-xs text-gray-400 text-center">
.blueprint/manifest.yaml
</p>
</div>
) : !blueprint ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center text-2xl">🗺</div>
@ -353,13 +506,19 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
<ModuleDetail
module={sidePanel.module}
areas={blueprint?.manifest.areas ?? []}
onClose={() => setSidePanel(null)}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
) : sidePanel.type === "remaining" ? (
<RemainingTasksPanel
modules={blueprint?.manifest.modules ?? []}
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
) : (
<NextActionsPanel
modules={blueprint?.manifest.modules ?? []}
onSelectModule={(mod) => setSidePanel({ type: "module", module: mod })}
onClose={() => setSidePanel(null)}
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
)}
</div>
@ -392,6 +551,7 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule
const groups: Record<string, BlueprintTask[]> = {
done: [],
in_progress: [],
blocked: [],
todo: [],
concept: [],
};
@ -405,6 +565,7 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule
const groupLabels: Record<string, string> = {
done: "已完成",
in_progress: "进行中",
blocked: "受阻",
todo: "待开发",
concept: "构思中",
};
@ -463,7 +624,7 @@ function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule
{/* 任务卡列表 */}
{mod.tasks.length > 0 && (
<div className="space-y-3">
{(["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
<div
onClick={() => 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"
}`}
>
<div className="flex items-center gap-2">
@ -543,6 +709,20 @@ function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: stri
)}
</div>
{/* locked 依赖提示,始终显示 */}
{task.locked && task.depends && (
<div className="mt-1.5 px-2 py-1.5 bg-gray-100 border border-gray-200 rounded text-[11px] text-gray-500">
<span className="font-semibold text-gray-400">:</span> {task.depends}
</div>
)}
{/* blocked_reason 始终显示,不需要展开 */}
{task.status === "blocked" && task.blocked_reason && (
<div className="mt-1.5 px-2 py-1.5 bg-red-50 border border-red-100 rounded text-[11px] text-red-600">
<span className="font-semibold text-red-500">:</span> {task.blocked_reason}
</div>
)}
{expanded && (
<div className="mt-2 pt-2 border-t border-gray-100 space-y-1 text-[11px] text-gray-500">
{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({
<p className="text-xs text-gray-400 text-center py-6"></p>
) : (
<div className="space-y-3">
{actionItems.filter((i) => i.task.status === "blocked").length > 0 && (
<div>
<p className="text-[10px] font-semibold text-red-500 uppercase tracking-wider mb-1.5"> </p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.task.status === "blocked")
.map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-red-500 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
)}
{actionItems.filter((i) => i.task.status === "in_progress").length > 0 && (
<div>
<p className="text-[10px] font-semibold text-blue-500 uppercase tracking-wider mb-1.5"></p>
@ -649,6 +851,97 @@ function NextActionsPanel({
</div>
</div>
)}
{actionItems.filter((i) => i.task.locked).length > 0 && (
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5"></p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.task.locked)
.map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-gray-600 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ── 未完成任务面板 ──────────────────────────────────────────────────────────
const REMAINING_ORDER: Record<string, number> = { blocked: 0, in_progress: 1, todo: 2, concept: 3 };
const REMAINING_LABEL: Record<string, string> = { blocked: "受阻", in_progress: "进行中", todo: "待开发", concept: "构思中" };
const REMAINING_COLOR: Record<string, string> = { 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<string, { module: BlueprintModule; task: BlueprintTask }[]> = {};
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 (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800"></h3>
<p className="text-[10px] text-gray-400 mt-0.5"> {totalRemaining} </p>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{totalRemaining === 0 ? (
<p className="text-xs text-gray-400 text-center py-6"></p>
) : (
<div className="space-y-3">
{sortedKeys.map((status) => (
<div key={status}>
<p className={`text-[10px] font-semibold uppercase tracking-wider mb-1.5 ${REMAINING_COLOR[status] ?? "text-gray-400"}`}>
{REMAINING_LABEL[status] ?? status} ({grouped[status].length})
</p>
<div className="space-y-1.5">
{grouped[status].map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-blue-500 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
))}
</div>
)}
</div>

View File

@ -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 (
<button
onClick={copy}
className="absolute -top-1.5 right-4 w-4 h-4 rounded-full bg-gray-400 hover:bg-blue-500 text-white text-[9px] flex items-center justify-center shadow transition-colors z-10 opacity-0 group-hover:opacity-100"
title="复制"
>
</button>
);
}
// ── 数据节点 ─────────────────────────────────────────────────────
function ProjectNode({ id, data }: NodeProps) {
@ -81,6 +110,7 @@ function ProjectNode({ id, data }: NodeProps) {
return (
<div className="group relative bg-white border-2 border-blue-300 rounded-xl shadow-sm w-48 select-none">
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 pt-2.5 pb-2">
@ -104,6 +134,7 @@ function GitNode({ id, data }: NodeProps) {
<div className="group relative bg-slate-50 border border-slate-300 rounded-lg shadow-sm w-44 select-none cursor-pointer hover:shadow-md transition-shadow"
onClick={() => openUrl(url).catch(() => {})}>
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
@ -122,6 +153,7 @@ function EnvNode({ id, data }: NodeProps) {
<div className={`group relative rounded-lg shadow-sm border w-44 select-none cursor-pointer hover:shadow-md transition-shadow ${isProd ? "bg-emerald-50 border-emerald-300" : "bg-amber-50 border-amber-300"}`}
onClick={() => openUrl(url).catch(() => {})}>
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
@ -138,6 +170,7 @@ function ServerNode({ id, data }: NodeProps) {
return (
<div className="group relative bg-slate-50 border border-slate-300 rounded-lg shadow-sm w-44 select-none">
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
@ -154,6 +187,7 @@ function FocusNode({ id, data }: NodeProps) {
return (
<div className="group relative bg-indigo-50 border border-indigo-200 rounded-lg shadow-sm w-48 select-none">
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
@ -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 */}
<Handle id="top" type="target" position={Position.Top}
className="opacity-30 group-hover:opacity-100 transition-opacity"
style={{ ...handleStyle, top: -4 }} />
{/* 底部发起子节点连线source */}
<Handle id="bottom" type="source" position={Position.Bottom}
className="opacity-30 group-hover:opacity-100 transition-opacity"
style={{ ...handleStyle, bottom: -4 }} />
{/* 左右保留隐形 handle 供通用连线使用 */}
<Handle id="left" type="target" position={Position.Left} style={{ opacity: 0 }} />
<Handle id="right" type="source" position={Position.Right} style={{ opacity: 0 }} />
{/* 删除 & 编辑按钮 */}
<DeleteBtn nodeId={id} />
<button
onClick={startEdit}
className="absolute -top-1.5 left-3 w-4 h-4 rounded-full bg-white border border-gray-200 hover:border-violet-400 text-gray-400 hover:text-violet-500 text-[9px] flex items-center justify-center shadow transition-colors z-10 opacity-0 group-hover:opacity-100"
title="编辑"
>
</button>
{/* hover 时顶部工具栏:编辑 / 复制 / 删除 */}
<div className="absolute -top-6 left-1/2 -translate-x-1/2 flex items-center gap-px bg-white border border-gray-200 rounded-full shadow px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity z-20">
<button
onClick={startEdit}
className="w-5 h-5 rounded-full hover:bg-violet-50 text-gray-400 hover:text-violet-500 text-[10px] flex items-center justify-center transition-colors"
title="编辑"
></button>
<button
onClick={(e) => {
e.stopPropagation();
const node = getNode(id);
if (!node) return;
addNodes([{ ...node, id: `custom-${Date.now()}-${Math.random().toString(36).slice(2,4)}`, position: { x: node.position.x + 20, y: node.position.y + 20 }, selected: false }]);
}}
className="w-5 h-5 rounded-full hover:bg-blue-50 text-gray-400 hover:text-blue-500 text-[10px] flex items-center justify-center transition-colors"
title="复制"
></button>
<button
onClick={(e) => { e.stopPropagation(); deleteElements({ nodes: [{ id }] }); }}
className="w-5 h-5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-400 text-[10px] flex items-center justify-center transition-colors"
title="删除"
>×</button>
</div>
<div className="px-3 py-2.5 flex items-center gap-2">
<span className="text-sm shrink-0" style={{ color: c }}></span>
@ -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 (
<div
className="relative rounded-2xl overflow-visible"
className="group/container relative rounded-2xl overflow-visible"
style={{ width: "100%", height: "100%", background: `${c}0c`, border: `2px solid ${c}` }}
>
<NodeResizer
@ -389,9 +462,10 @@ function ContainerNode({ id, data, selected }: NodeProps) {
</span>
)}
{/* 颜色色板(hover 显示) */}
{/* 颜色色板(绝对定位,不占 flex 空间,hover 显示) */}
{!editing && (
<div className="flex items-center gap-0.5 opacity-0 group-hover/container:opacity-100 transition-opacity">
<div className="absolute left-8 top-full mt-1 flex items-center gap-0.5 px-1.5 py-1 rounded-lg shadow-md opacity-0 group-hover/container:opacity-100 transition-opacity pointer-events-none group-hover/container:pointer-events-auto z-20"
style={{ background: `${c}22`, border: `1px solid ${c}44` }}>
{PRESET_COLORS.map((pc) => (
<button
key={pc}
@ -416,6 +490,12 @@ function ContainerNode({ id, data, selected }: NodeProps) {
style={{ background: c }}
title="添加子区域"
>+</button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={copyContainer}
className="shrink-0 w-4 h-4 rounded-full bg-gray-200 hover:bg-blue-400 text-white text-[9px] flex items-center justify-center transition-colors"
title="复制容器"
></button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={() => deleteElements({ nodes: [{ id }] })}
@ -431,7 +511,19 @@ function ContainerNode({ id, data, selected }: NodeProps) {
function ContainerChildNode({ id, data }: NodeProps) {
const { name, color } = data as ContainerChildNodeData;
const { updateNodeData, deleteElements } = useReactFlow();
const { updateNodeData, deleteElements, getNode, addNodes } = useReactFlow();
const copyChild = (e: React.MouseEvent) => {
e.stopPropagation();
const node = getNode(id);
if (!node) return;
addNodes([{
...node,
id: `cchild-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`,
position: { x: node.position.x + 16, y: node.position.y + 16 },
selected: false,
}]);
};
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState(name);
const c = color || DEFAULT_CUSTOM_COLOR;
@ -471,6 +563,12 @@ function ContainerChildNode({ id, data }: NodeProps) {
{name}
</span>
)}
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={copyChild}
className="shrink-0 w-3.5 h-3.5 rounded-full bg-gray-200 hover:bg-blue-400 text-white text-[8px] flex items-center justify-center transition-colors opacity-0 group-hover/child:opacity-100"
title="复制"
></button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={() => deleteElements({ nodes: [{ id }] })}
@ -492,6 +590,144 @@ const nodeTypes = {
containerChild: ContainerChildNode,
};
// ── 可编辑标签边(支持拖动路径 + 文本标签)─────────────────────
function LabeledEdge({
id, sourceX, sourceY, targetX, targetY,
style, markerEnd, data,
}: EdgeProps) {
const { setEdges, screenToFlowPosition } = useReactFlow();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState((data?.label as string) ?? "");
const label = (data?.label as string) ?? "";
const cpOffset = (data?.cpOffset as { x: number; y: number }) ?? { x: 0, y: 0 };
const strokeColor = (style?.stroke as string) ?? "#9ca3af";
// 控制点 = 中点 + 用户拖动偏移flow 坐标)
const cpX = (sourceX + targetX) / 2 + cpOffset.x;
const cpY = (sourceY + targetY) / 2 + cpOffset.y;
// 二次贝塞尔路径
const edgePath = `M ${sourceX} ${sourceY} Q ${cpX} ${cpY} ${targetX} ${targetY}`;
// t=0.5 时的曲线坐标(标签位置)
const labelX = (sourceX + 2 * cpX + targetX) / 4;
const labelY = (sourceY + 2 * cpY + targetY) / 4;
// 拖动状态ref 避免重渲染)
const dragStartFlow = useRef<{ x: number; y: number } | null>(null);
const dragStartCp = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation();
dragStartFlow.current = screenToFlowPosition({ x: e.clientX, y: e.clientY });
dragStartCp.current = { ...cpOffset };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragStartFlow.current) return;
const cur = screenToFlowPosition({ x: e.clientX, y: e.clientY });
const dx = cur.x - dragStartFlow.current.x;
const dy = cur.y - dragStartFlow.current.y;
setEdges((eds) =>
eds.map((edge) =>
edge.id === id
? { ...edge, data: { ...edge.data, cpOffset: { x: dragStartCp.current.x + dx, y: dragStartCp.current.y + dy } } }
: edge,
),
);
};
const onHandlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
dragStartFlow.current = null;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
};
const commitLabel = (value: string) => {
setEdges((eds) =>
eds.map((e) => e.id === id ? { ...e, data: { ...e.data, label: value.trim() } } : e),
);
setEditing(false);
};
return (
<>
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
<EdgeLabelRenderer>
{/* 路径拖动把手(悬停显示,双击恢复直线) */}
<div
style={{
transform: `translate(-50%,-50%) translate(${cpX}px,${cpY}px)`,
pointerEvents: "all",
borderColor: strokeColor,
}}
className="absolute nodrag nopan w-3 h-3 rounded-full bg-white border-2 cursor-grab active:cursor-grabbing opacity-20 hover:opacity-100 transition-opacity z-10"
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onDoubleClick={(e) => {
e.stopPropagation();
setEdges((eds) =>
eds.map((edge) =>
edge.id === id ? { ...edge, data: { ...edge.data, cpOffset: { x: 0, y: 0 } } } : edge,
),
);
}}
title="拖动调整路径 · 双击恢复直线"
/>
{/* 文本标签 */}
<div
style={{
transform: `translate(-50%,-50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "all",
}}
className="absolute nodrag nopan"
>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => commitLabel(draft)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitLabel(draft);
if (e.key === "Escape") { setDraft(label); setEditing(false); }
}}
onPointerDown={(e) => e.stopPropagation()}
className="text-[10px] px-1.5 py-0.5 rounded border border-gray-300 bg-white outline-none focus:ring-1 focus:ring-blue-400 shadow-sm min-w-[60px] max-w-[160px]"
style={{ color: strokeColor }}
/>
) : label ? (
<span
onDoubleClick={() => { setDraft(label); setEditing(true); }}
className="text-[10px] px-1.5 py-0.5 rounded bg-white border border-gray-200 shadow-sm cursor-text select-none whitespace-nowrap"
style={{ color: strokeColor }}
title="双击编辑"
>
{label}
</span>
) : (
<span
onDoubleClick={() => { setDraft(""); setEditing(true); }}
className="text-[10px] px-1 py-0.5 rounded text-gray-300 hover:text-gray-400 hover:bg-white hover:border hover:border-gray-200 cursor-text select-none transition-colors"
title="双击添加标签"
>
</span>
)}
</div>
</EdgeLabelRenderer>
</>
);
}
const edgeTypes = {
labeled: LabeledEdge,
};
// ── 持久化类型 ────────────────────────────────────────────────────
interface PersistedCanvas {
@ -501,7 +737,7 @@ interface PersistedCanvas {
parentId?: string;
extent?: "parent";
}[];
edges: { id: string; source: string; target: string; style?: React.CSSProperties; type?: string }[];
edges: { id: string; source: string; target: string; style?: React.CSSProperties; type?: string; data?: Record<string, unknown>; markerEnd?: unknown }[];
customItems: CustomItem[];
}
@ -913,9 +1149,17 @@ function CanvasInner({ groups, groupId }: { groups: Record<string, SidebarItem[]
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [customItems, setCustomItems] = useState<CustomItem[]>([]);
const { screenToFlowPosition, getNode } = useReactFlow();
const { screenToFlowPosition, getNode, deleteElements } = useReactFlow();
const canvasRef = useRef<HTMLDivElement>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [edgeMenu, setEdgeMenu] = useState<{ edgeId: string; x: number; y: number } | null>(null);
// 点击其他地方关闭右键菜单
useEffect(() => {
const close = () => setEdgeMenu(null);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, []);
// 初始加载(从数据库)
useEffect(() => {
@ -944,7 +1188,7 @@ function CanvasInner({ groups, groupId }: { groups: Record<string, SidebarItem[]
...(parentId ? { parentId } : {}),
...(extent === "parent" ? { extent: "parent" as const } : {}),
})),
edges: edges.map(({ id, source, target, style, type }) => ({ id, source, target, style, type })),
edges: edges.map(({ id, source, target, style, type, data, markerEnd }) => ({ id, source, target, style, type, data: data as Record<string, unknown> | undefined, markerEnd })),
customItems,
};
saveCanvasState(groupId, JSON.stringify(state)).catch(() => {});
@ -955,13 +1199,15 @@ function CanvasInner({ groups, groupId }: { groups: Record<string, SidebarItem[]
// 自定义节点之间的连线用彩色箭头,其余用灰色
const onConnect: OnConnect = useCallback(
(conn) => {
if (conn.source === conn.target) return; // 禁止自环
const src = getNode(conn.source);
const tgt = getNode(conn.target);
const bothCustom = src?.type === "custom" && tgt?.type === "custom";
const edgeColor = bothCustom ? ((src?.data as CustomNodeData).color || DEFAULT_CUSTOM_COLOR) : "#d1d5db";
setEdges((eds) => addEdge({
...conn,
type: bothCustom ? "smoothstep" : "default",
type: "labeled",
data: { label: "", _smooth: bothCustom },
style: { stroke: edgeColor, strokeWidth: 1.5 },
markerEnd: bothCustom
? { type: MarkerType.ArrowClosed, color: edgeColor, width: 12, height: 12 }
@ -1031,9 +1277,14 @@ function CanvasInner({ groups, groupId }: { groups: Record<string, SidebarItem[]
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
setEdgeMenu({ edgeId: edge.id, x: e.clientX, y: e.clientY });
}}
nodesDraggable
panOnDrag
zoomOnScroll
@ -1053,6 +1304,26 @@ function CanvasInner({ groups, groupId }: { groups: Record<string, SidebarItem[]
</div>
)}
</ReactFlow>
{/* 边右键菜单 */}
{edgeMenu && (
<div
style={{ position: "fixed", left: edgeMenu.x, top: edgeMenu.y, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[110px]"
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-[12px] text-red-500 hover:bg-red-50 transition-colors rounded-lg"
onClick={() => {
deleteElements({ edges: [{ id: edgeMenu.edgeId }] });
setEdgeMenu(null);
}}
>
线
</button>
</div>
)}
{nodes.length > 0 && (
<div className="absolute top-3 right-3 flex gap-1.5 z-10">
{nodes.some((n) => n.type === "custom") && (

View File

@ -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;
}