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:
parent
0cdaa3678c
commit
a2596413c5
@ -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 任务 → 分析原因,拆/补/调后产出新任务卡
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
29
.blueprint/modules/blueprint-feedback.md
Normal file
29
.blueprint/modules/blueprint-feedback.md
Normal 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 分析」按钮,生成汇总报告
|
||||
48
.blueprint/modules/blueprint-governance.md
Normal file
48
.blueprint/modules/blueprint-governance.md
Normal 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 遍历所有已注册项目,返回每个项目的同步结果摘要
|
||||
38
.blueprint/modules/blueprint-onboarding.md
Normal file
38
.blueprint/modules/blueprint-onboarding.md
Normal 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.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 执行");🔘状态显示「初始化蓝图」提示词,🟠状态显示「同步描绘」提示词
|
||||
@ -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 作为任务卡可选字段,支持执行模型上报阻塞信息
|
||||
|
||||
@ -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/ 文件作为模型间共享状态
|
||||
|
||||
@ -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`(含 areas、modules、edges)
|
||||
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!(
|
||||
|
||||
@ -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 单独 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 (
|
||||
<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>
|
||||
|
||||
@ -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") && (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user