feat: add Blueprint feature to ProjectCard and commands

- Introduced BlueprintModal in ProjectCard for project blueprint visualization.
- Added state management for displaying the BlueprintModal.
- Implemented Blueprint-related interfaces and functions in commands.ts for handling blueprint data.
This commit is contained in:
lanrtop 2026-04-02 19:12:53 +09:00
parent be7db96d48
commit 0cdaa3678c
40 changed files with 3967 additions and 75 deletions

163
.blueprint/CONVENTIONS.md Normal file
View File

@ -0,0 +1,163 @@
# 蓝图更新规则
本文件定义了 `.blueprint/` 目录的维护规范,供 Claude 在对话中遵循。
## 目录结构
```
.blueprint/
├── manifest.yaml ← 模块列表、关系、布局坐标
├── CONVENTIONS.md ← 本文件Claude 行为规则)
└── modules/
└── <module-id>.md ← 每个模块的详情 + 任务卡
```
## 何时更新蓝图
| 事件 | 操作 |
|------|------|
| 用户确认了新需求 | 在 manifest.yaml 新增模块,创建 modules/<id>.md |
| 完成了功能实现 | 更新模块 status → done标记任务卡 ✅ |
| 拆分了大需求为子任务 | 在 modules/<id>.md 中添加任务卡 |
| 调整了架构方向 | 更新 edges 关系,可能调整 area |
| 放弃了某个方向 | 删除对应模块和文件,或标记 status: abandoned |
## 模块状态定义
| 状态 | 含义 | 色标 |
|------|------|------|
| `done` | 已实现并验证 | 🟢 绿色 |
| `in_progress` | 正在开发 | 🔵 蓝色 |
| `planned` | 已规划,需求明确 | 🟡 黄色 |
| `concept` | 构思中,信息不完整 | ⚪ 灰色 |
## 任务卡格式
modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
```markdown
### 📋 任务标题
- status: todo
- complexity: M
- files: src/path/to/file.tsx, src-tauri/src/commands/xxx.rs
- depends: other-module-id
- acceptance: 简要描述完成标准
详细说明(可选)。
```
### 状态前缀
| 前缀 | status | 含义 |
|------|--------|------|
| ✅ | done | 已完成 |
| 🔵 | in_progress | 进行中 |
| 📋 | todo | 可派发(信息充足) |
| 💭 | concept | 构思中 |
### 可派发标准(📋)
任务卡同时满足以下条件时标记为 📋:
1. status 为 `todo`
2. 有明确的 `files`(涉及哪些文件)
3. 有明确的 `acceptance`(验收标准)
4. complexity 为 `S``M`
不满足条件的 todo 任务保持无前缀,说明还需补充信息。
## manifest.yaml 规范
### 模块字段
```yaml
- id: kebab-case-id # 唯一标识,同时是 modules/ 下的文件名
name: 显示名称
area: frontend # 所属领域 ID
status: done # done / in_progress / planned / concept
progress: 100 # 0~100done 自动视为 100
position: [x, y] # React Flow 画布坐标(可选)
```
### 边字段
```yaml
- from: module-a
to: module-b
type: dependency # dependency实线/ related虚线
```
### 领域字段
```yaml
- id: frontend
name: 前端
color: "#3B82F6" # 节点边框/分组背景色
```
## 进度计算
模块的 progress 由其任务卡的完成比例推算:
- 无任务卡的模块:手动设置 progress
- 有任务卡的模块:`完成数 / 总数 × 100`(取整)
## 决策记录格式
模块文件中可添加 `## 决策记录` 段落,记录架构决策的 WHY
```markdown
## 决策记录
- 选择阿里云 OSS 而非 GitHub Releases 作为更新源,因为国内用户访问 GitHub 不稳定
- 使用 document-driven 蓝图而非数据库存储,因为 Claude 天然支持文件读写且可版本控制
```
桌面 App 会以琥珀色卡片展示在模块详情面板中。
## 飞轮工作流
Claude 在每次对话中应遵循以下流程:
### 阶段 1进入对话 — 读取蓝图
```
读取 .blueprint/manifest.yaml → 了解项目全景和当前状态
```
- 识别哪些模块 in_progress哪些有可派发任务
- 用简短话语告诉用户当前项目进展(如 "18/22 模块已完成2 个进行中"
### 阶段 2讨论需求 — 同步更新蓝图
| 用户说的 | Claude 应做的 |
|----------|--------------|
| "我想加个 XX 功能" | 确认需求后manifest 加模块 (status: concept),创建 modules/<id>.md |
| "XX 功能需要 A、B、C 三步" | 在模块文件中拆分为 3 张任务卡 |
| "这个方案确定了" | 模块 status → planned补充任务卡的 files/acceptance |
| "开始做 XX" | 模块 status → in_progress |
| "为什么选这个方案" | 记录到模块的 `## 决策记录` |
### 阶段 3实现功能 — 标记完成
```
完成代码 → 更新任务卡前缀为 ✅ → status: done
所有任务卡完成 → 模块 status: done
更新 manifest.yaml 的 updated 日期
```
### 阶段 4回顾 — 用户在桌面 App 查看
用户打开蓝图可视化:
- 看到全景进度
- 在「下一步」面板查看可派发任务
- 点击「复制派发」→ 粘贴给新 Claude 会话
### 飞轮的反馈信号
Claude 应关注这些信号来决定是否更新蓝图:
- 用户说"这个做完了" → 标记 done
- 用户说"这个不做了" → 标记 abandoned 或删除
- 用户说"这个要拆一下" → 拆分任务卡
- 用户说"记住这个决策" → 写入决策记录
- 用户没提蓝图但在讨论功能 → 主动提议更新蓝图
## 注意事项
- 不要删除已完成的任务卡,它们是项目历史的一部分
- 模块 id 一旦确定尽量不要修改(会影响 edges 引用)
- 每次更新后修改 manifest.yaml 顶部的 `updated` 日期
- progress 由桌面 App 自动从任务卡完成比例计算,不需要手动维护
- 当用户的操作隐含了蓝图变更但没有明说时Claude 应主动提议更新(而非沉默)

223
.blueprint/manifest.yaml Normal file
View File

@ -0,0 +1,223 @@
version: 1
name: Dev Manager Tauri
description: Git 多空间开发管理器集成项目管理、Git 操作、CI/CD、开发工具链管理
updated: 2026-04-02
areas:
- id: frontend
name: 前端
color: "#3B82F6"
- id: backend
name: 后端
color: "#10B981"
- id: infra
name: 基础设施
color: "#F59E0B"
modules:
# ── 前端 ────────────────────────────────────────
- id: workspace-mgmt
name: 空间管理
area: frontend
status: done
progress: 100
position: [0, 0]
- id: project-mgmt
name: 项目管理
area: frontend
status: done
progress: 100
position: [250, 0]
- id: project-dashboard
name: 项目看板
area: frontend
status: done
progress: 100
position: [500, 0]
- id: repo-registry
name: Git 仓库注册
area: frontend
status: done
progress: 100
position: [250, 150]
- id: project-tools
name: 项目工具链
area: frontend
status: done
progress: 100
position: [500, 150]
- id: project-templates
name: 项目模板
area: frontend
status: done
progress: 100
position: [0, 150]
- id: tags-groups
name: 标签 & 分组
area: frontend
status: done
progress: 100
position: [750, 0]
- id: settings-page
name: 设置页
area: frontend
status: done
progress: 100
position: [750, 150]
- id: blueprint-view
name: 蓝图可视化
area: frontend
status: in_progress
progress: 10
position: [1000, 0]
# ── 后端 ────────────────────────────────────────
- id: git-ops
name: Git 操作
area: backend
status: done
progress: 100
position: [0, 350]
- id: github-auth
name: GitHub OAuth
area: backend
status: done
progress: 100
position: [250, 350]
- id: github-publish
name: GitHub 发布
area: backend
status: done
progress: 100
position: [500, 350]
- id: devtools-scan
name: 开发工具扫描
area: backend
status: done
progress: 100
position: [750, 350]
- id: project-env-scan
name: 项目环境扫描
area: backend
status: done
progress: 100
position: [750, 500]
- id: shell-launch
name: 编辑器 & 终端启动
area: backend
status: done
progress: 100
position: [500, 500]
- id: blueprint-reader
name: 蓝图文件读取
area: backend
status: planned
progress: 0
position: [1000, 350]
# ── 基础设施 ────────────────────────────────────
- id: cicd-workflow
name: CI/CD 工作流
area: infra
status: done
progress: 100
position: [0, 650]
- id: auto-update
name: 客户端自动更新
area: infra
status: done
progress: 90
position: [250, 650]
- id: wsl-proxy
name: WSL 代理同步
area: infra
status: done
progress: 100
position: [500, 650]
- id: v2rayn-import
name: v2rayN 节点读取
area: infra
status: done
progress: 100
position: [500, 800]
- id: sqlite-db
name: SQLite 数据层
area: infra
status: done
progress: 100
position: [750, 650]
edges:
# 前端依赖
- from: workspace-mgmt
to: project-mgmt
type: dependency
- from: project-mgmt
to: project-dashboard
type: dependency
- from: repo-registry
to: project-dashboard
type: related
- from: project-mgmt
to: tags-groups
type: related
- from: project-mgmt
to: blueprint-view
type: dependency
# 后端依赖
- from: github-auth
to: github-publish
type: dependency
- from: git-ops
to: github-publish
type: dependency
# 前后端关联
- from: git-ops
to: project-dashboard
type: related
- from: github-auth
to: project-dashboard
type: related
- from: devtools-scan
to: project-tools
type: related
- from: project-env-scan
to: project-tools
type: related
- from: blueprint-reader
to: blueprint-view
type: dependency
# 基础设施关联
- from: cicd-workflow
to: auto-update
type: dependency
- from: v2rayn-import
to: wsl-proxy
type: dependency
- from: sqlite-db
to: project-mgmt
type: dependency
- from: auto-update
to: settings-page
type: related

View File

@ -0,0 +1,40 @@
---
id: auto-update
name: 客户端自动更新
status: done
progress: 90
---
## 概述
基于 tauri-plugin-updater 的客户端自动更新,检查阿里云 OSS 上的 latest.json发现新版本后下载安装。
完整指南见 docs/tauri-updater-guide.md。
## 任务卡
### ✅ 更新检测
- status: done
- files: src/components/settings/SettingsPage.tsx
点击「检查更新」请求 latest.json 比对版本号。
### ✅ 下载 & 安装
- status: done
- files: src/components/settings/SettingsPage.tsx
下载安装包(带进度条),安装后自动重启。
### ✅ 错误提示
- status: done
- files: src/components/settings/SettingsPage.tsx
错误信息完整展示,支持一键复制。
### ✅ Capabilities 权限配置
- status: done
- files: src-tauri/capabilities/default.json
updater:default + process:default + http 域名放行。
### 💭 自动检查更新
- status: concept
- notes: 应用启动时自动检查,发现新版本后在状态栏提示,而非需要手动进入设置页。

View File

@ -0,0 +1,29 @@
---
id: blueprint-reader
name: 蓝图文件读取
status: planned
progress: 0
---
## 概述
Rust 后端 tauri command读取项目目录下的 .blueprint/ 文件并解析为结构化数据返回给前端。
## 任务卡
### 📋 读取 manifest.yaml
- status: todo
- complexity: M
- files: src-tauri/src/commands/blueprint.rs (待创建)
- acceptance: 读取并解析 manifest.yaml返回 areas、modules、edges 结构体
### 📋 读取 modules/*.md
- status: todo
- complexity: M
- files: src-tauri/src/commands/blueprint.rs (待创建)
- acceptance: 解析 markdown frontmatter 和内容提取任务卡列表标题、status、complexity、files、acceptance
### 📋 合并返回完整蓝图数据
- status: todo
- complexity: S
- files: src-tauri/src/commands/blueprint.rs (待创建)
- acceptance: 一次调用返回完整蓝图manifest + 所有模块详情 + 任务卡),前端无需多次请求

View File

@ -0,0 +1,47 @@
---
id: blueprint-view
name: 蓝图可视化
status: in_progress
progress: 10
---
## 概述
读取项目 .blueprint/ 目录中的 manifest.yaml 和 modules/*.md用 React Flow 渲染项目全景架构图。点击模块节点展示详情和任务卡列表。
## 任务卡
### ✅ .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 (待创建)
- acceptance: 实现 get_blueprint tauri command读取指定项目路径下的 .blueprint/manifest.yaml 和 modules/*.md解析后返回结构化数据
### 📋 React Flow 节点图渲染
- status: todo
- complexity: L
- files: src/components/dashboard/BlueprintModal.tsx (待创建)
- depends: blueprint-reader
- acceptance: 将 manifest.yaml 的 modules 渲染为自定义节点带状态色标、进度条edges 渲染为连线areas 渲染为分组背景
### 📋 模块详情面板
- status: todo
- complexity: M
- files: src/components/dashboard/BlueprintModal.tsx
- depends: blueprint-reader
- acceptance: 点击节点后在底部/侧边展示模块描述和任务卡列表,任务卡按状态分组显示
### 📋 项目卡片蓝图入口
- status: todo
- complexity: S
- files: src/components/dashboard/ProjectCard.tsx
- acceptance: 项目卡片新增「蓝图」按钮,点击打开 BlueprintModal检测到无 .blueprint/ 时显示引导提示
### 💭 统计汇总条
- status: concept
- notes: 模态框顶部展示项目完成度统计条(已完成 X / 进行中 X / 规划中 X / 构思中 X

View File

@ -0,0 +1,42 @@
---
id: cicd-workflow
name: CI/CD 工作流
status: done
progress: 100
---
## 概述
CI/CD 配置管理和 GitHub Actions Workflow 文件生成。支持两种部署类型Web SSH 部署和 Tauri OSS 分发。
完整部署教程见 docs/tauri-cicd-guide.md。
## 任务卡
### ✅ CI/CD 配置 CRUD
- status: done
- files: src-tauri/src/commands/cicd.rs, src/components/dashboard/CicdModal.tsx
保存和读取 CI/CD 配置(部署类型、触发分支、构建命令等)。
### ✅ Workflow 文件生成
- status: done
- files: src-tauri/src/commands/cicd.rs
根据配置自动生成 .github/workflows/ 下的 YAML 文件。
### ✅ Web SSH 部署模板
- status: done
- files: src-tauri/src/commands/cicd.rs
生成基于 appleboy/ssh-action 的部署 workflow。
### ✅ Tauri OSS 分发模板
- status: done
- files: src-tauri/src/commands/cicd.rs
生成 Tauri 构建 + 签名 + 阿里云 OSS 上传的 release workflow。
### ✅ Actions 运行历史
- status: done
- files: src/components/dashboard/CicdModal.tsx
查看 GitHub Actions 运行状态,支持手动触发。

View File

@ -0,0 +1,29 @@
---
id: devtools-scan
name: 开发工具扫描
status: done
progress: 100
---
## 概述
扫描系统和 WSL 中安装的开发工具Node.js、pnpm、Rust、Python、Docker 等),统一展示版本信息。
## 任务卡
### ✅ Windows 工具扫描
- status: done
- files: src-tauri/src/commands/devtools.rs, src/components/devtools/
扫描 Windows 环境中的开发工具及版本。
### ✅ WSL 工具扫描
- status: done
- files: src-tauri/src/commands/devtools.rs
通过 WSL 命令扫描 Linux 环境中的开发工具。
### ✅ 工具信息持久化
- status: done
- files: src-tauri/src/commands/devtools.rs
保存、更新、删除扫描到的工具信息。

View File

@ -0,0 +1,37 @@
---
id: git-ops
name: Git 操作
status: done
progress: 100
---
## 概述
基于 libgit2 (git2-rs) 的 Git 操作后端,提供 clone、status、fetch、pull、push、分支管理等功能。
## 任务卡
### ✅ 基础操作
- status: done
- files: src-tauri/src/commands/git_ops.rs
git clone, status, fetch, pull, push, head commit 查询。
### ✅ 分支管理
- status: done
- files: src-tauri/src/commands/git_ops.rs
创建分支、列举分支、切换分支。
### ✅ 上游状态检测
- status: done
- files: src-tauri/src/commands/git_ops.rs
检测本地分支落后远程的提交数。
### 💭 冲突解决 UI
- status: concept
- notes: 检测到冲突文件时提供可视化 diff 和解决方案,尚未确定交互形式。
### 💭 Stash 管理
- status: concept
- notes: stash list/apply/drop可能需要新的 UI 面板。

View File

@ -0,0 +1,23 @@
---
id: github-auth
name: GitHub OAuth
status: done
progress: 100
---
## 概述
GitHub OAuth Device Flow 认证,获取 token 后支持 API 操作和 Git 推送。
## 任务卡
### ✅ OAuth Device Flow
- status: done
- files: src-tauri/src/commands/github.rs, src/components/auth/
启动 OAuth 流程、轮询 token、保存账户信息。
### ✅ Token 管理
- status: done
- files: src-tauri/src/commands/github.rs
保存、读取、清除 GitHub token支持登出。

View File

@ -0,0 +1,35 @@
---
id: github-publish
name: GitHub 发布
status: done
progress: 100
---
## 概述
将本地项目发布到 GitHub支持初始化 Git 仓库、创建远程仓库、推送代码、生成 .gitignore。
## 任务卡
### ✅ 扫描本地 Git 状态
- status: done
- files: src-tauri/src/commands/publish.rs
检测项目是否已初始化 Git、是否有远程仓库。
### ✅ 创建 GitHub 仓库
- status: done
- files: src-tauri/src/commands/publish.rs
通过 GitHub API 创建新仓库。
### ✅ Git 初始化 & 首次推送
- status: done
- files: src-tauri/src/commands/publish.rs
git init、首次 commit、设置 remote、push。
### ✅ .gitignore 生成
- status: done
- files: src-tauri/src/commands/publish.rs
根据项目类型生成 .gitignore 文件。

View File

@ -0,0 +1,35 @@
---
id: project-dashboard
name: 项目看板
status: done
progress: 100
---
## 概述
当前空间的项目看板视图,展示项目卡片列表,支持分组、搜索、快捷操作(打开编辑器/终端、Git 操作、CI/CD 等)。
## 任务卡
### ✅ 项目卡片列表
- status: done
- files: src/components/dashboard/Dashboard.tsx, src/components/dashboard/ProjectCard.tsx
卡片展示项目名称、状态、标签,支持搜索过滤。
### ✅ 分组画布
- status: done
- files: src/components/dashboard/GroupResourceCanvas.tsx
自定义分组,拖拽管理项目归属。
### ✅ 项目快捷操作
- status: done
- files: src/components/dashboard/ProjectCard.tsx
从卡片直接打开编辑器、终端、Git 操作面板。
### ✅ 项目发现面板
- status: done
- files: src/components/dashboard/DiscoveryPanel.tsx
发现未关联的本地项目。

View File

@ -0,0 +1,23 @@
---
id: project-env-scan
name: 项目环境扫描
status: done
progress: 100
---
## 概述
扫描单个项目目录中使用的技术栈和工具版本(如 package.json 中的 Node 版本、Cargo.toml 中的 Rust edition 等)。
## 任务卡
### ✅ 项目环境检测
- status: done
- files: src-tauri/src/commands/project_scan.rs, src/components/dashboard/ProjectEnvModal.tsx
分析项目目录结构,识别技术栈和版本要求。
### ✅ 环境信息持久化
- status: done
- files: src-tauri/src/commands/project_scan.rs
保存和读取项目环境工具信息。

View File

@ -0,0 +1,35 @@
---
id: project-mgmt
name: 项目管理
status: done
progress: 100
---
## 概述
项目的创建、编辑、删除,支持批量导入。项目分为 profile跨空间共享的档案和 workspace空间内的挂载实例两层。
## 任务卡
### ✅ 项目 CRUD
- status: done
- files: src/components/manage/ManagePage.tsx, src-tauri/src/commands/projects.rs
基础的项目增删改查。
### ✅ 批量导入项目
- status: done
- files: src/components/dashboard/BatchAddModal.tsx
扫描文件夹子目录,批量创建项目。
### ✅ 项目档案Profile
- status: done
- files: src-tauri/src/commands/projects.rs
跨空间共享的项目元信息:技术栈、仓库 URL、部署地址等。
### ✅ 子文件夹扫描
- status: done
- files: src-tauri/src/commands/projects.rs
扫描指定目录下的子文件夹,辅助批量添加。

View File

@ -0,0 +1,29 @@
---
id: project-templates
name: 项目模板
status: done
progress: 100
---
## 概述
预设项目模板,包含内置模板(首次启动自动种植)和用户自定义模板,用于快速创建新项目。
## 任务卡
### ✅ 模板 CRUD
- status: done
- files: src/components/manage/TemplateEditor.tsx, src-tauri/src/commands/templates.rs
创建、编辑、删除项目模板。
### ✅ 从模板创建项目
- status: done
- files: src-tauri/src/commands/templates.rs
选择模板后自动填充项目信息。
### ✅ 内置模板种植
- status: done
- files: src-tauri/src/commands/templates.rs
首次启动时自动插入预设模板。

View File

@ -0,0 +1,23 @@
---
id: project-tools
name: 项目工具链
status: done
progress: 100
---
## 概述
管理项目关联的开发工具数据库客户端、API 测试工具等),支持一键启动。
## 任务卡
### ✅ 工具 CRUD
- status: done
- files: src/components/dashboard/ProjectToolsModal.tsx, src-tauri/src/commands/project_tools.rs
为项目添加/编辑/删除关联工具。
### ✅ 工具启动
- status: done
- files: src-tauri/src/commands/project_tools.rs
一键启动关联工具,支持自定义启动命令。

View File

@ -0,0 +1,29 @@
---
id: repo-registry
name: Git 仓库注册
status: done
progress: 100
---
## 概述
统一管理 Git 仓库信息,支持从 GitHub 批量导入仓库,挂载到项目。
## 任务卡
### ✅ 仓库导入
- status: done
- files: src/components/dashboard/ImportRepoModal.tsx, src-tauri/src/commands/git_repos.rs
从 GitHub 拉取用户仓库列表,批量导入到本地注册表。
### ✅ 仓库挂载
- status: done
- files: src/components/dashboard/MountRepoModal.tsx, src-tauri/src/commands/git_repos.rs
将已注册的仓库关联到项目。
### ✅ 仓库注册表管理
- status: done
- files: src/components/dashboard/RepoRegistryModal.tsx
查看、搜索、删除已注册仓库。

View File

@ -0,0 +1,35 @@
---
id: settings-page
name: 设置页
status: done
progress: 100
---
## 概述
全局和空间级设置包括编辑器检测、终端配置、WSL 发行版选择、应用更新。
## 任务卡
### ✅ 编辑器检测
- status: done
- files: src/components/settings/SettingsPage.tsx, src-tauri/src/commands/settings.rs
自动扫描系统安装的编辑器VS Code、Cursor 等)。
### ✅ 终端配置
- status: done
- files: src/components/settings/SettingsPage.tsx
配置默认终端程序。
### ✅ WSL 发行版选择
- status: done
- files: src/components/settings/SettingsPage.tsx
列举已安装的 WSL 发行版,选择默认使用的。
### ✅ 关于 & 更新
- status: done
- files: src/components/settings/SettingsPage.tsx
显示当前版本号、检查更新、下载安装、错误信息可复制。

View File

@ -0,0 +1,23 @@
---
id: shell-launch
name: 编辑器 & 终端启动
status: done
progress: 100
---
## 概述
从应用内一键打开编辑器VS Code、Cursor 等)和终端,支持 Windows 路径和 WSL 路径。
## 任务卡
### ✅ 打开编辑器
- status: done
- files: src-tauri/src/commands/shell.rs
调用配置的编辑器打开项目目录。
### ✅ 打开终端
- status: done
- files: src-tauri/src/commands/shell.rs
打开终端并 cd 到项目目录,支持 Windows Terminal、PowerShell 等。

View File

@ -0,0 +1,23 @@
---
id: sqlite-db
name: SQLite 数据层
status: done
progress: 100
---
## 概述
使用 rusqlite + r2d2 连接池管理本地 SQLite 数据库,存储项目、空间、配置、工具等所有持久化数据。
## 任务卡
### ✅ 连接池初始化
- status: done
- files: src-tauri/src/db.rs
应用启动时初始化数据库创建表结构r2d2 连接池管理。
### ✅ 数据库迁移
- status: done
- files: src-tauri/src/db.rs
表结构变更时的自动迁移逻辑。

View File

@ -0,0 +1,23 @@
---
id: tags-groups
name: 标签 & 分组
status: done
progress: 100
---
## 概述
项目标签系统和分组管理,用于项目分类和看板布局。
## 任务卡
### ✅ 标签管理
- status: done
- files: src/components/manage/TagsManager.tsx, src-tauri/src/commands/tags.rs
标签的增删改、颜色配置、项目关联。
### ✅ 分组管理
- status: done
- files: src-tauri/src/commands/groups.rs
分组的增删改、排序、成员管理。

View File

@ -0,0 +1,29 @@
---
id: v2rayn-import
name: v2rayN 节点读取
status: done
progress: 100
---
## 概述
读取本地 v2rayN 的 SQLite 数据库,获取当前激活节点的连接信息,供 WSL 代理同步使用。
## 任务卡
### ✅ 数据库路径检测
- status: done
- files: src-tauri/src/commands/proxy.rs, src/components/proxy/ProxyPage.tsx
自动扫描 v2rayN 的 guiNDB.db 路径,支持手动选择。
### ✅ 当前节点读取
- status: done
- files: src-tauri/src/commands/proxy.rs
读取 v2rayN 数据库中的当前激活节点信息(协议类型、地址、端口、备注)。
### ✅ 节点列表扫描
- status: done
- files: src-tauri/src/commands/proxy.rs
扫描全部节点列表。

View File

@ -0,0 +1,29 @@
---
id: workspace-mgmt
name: 空间管理
status: done
progress: 100
---
## 概述
多空间Workspace管理每个空间可独立挂载项目、配置编辑器和终端。用户可在空间间切换实现不同开发场景的隔离。
## 任务卡
### ✅ 空间 CRUD
- status: done
- files: src/components/spaces/SpacesPage.tsx, src-tauri/src/commands/spaces.rs
创建、编辑、删除空间,切换当前活跃空间。
### ✅ 空间级设置
- status: done
- files: src/components/settings/SettingsPage.tsx, src-tauri/src/commands/settings.rs
每个空间可配置独立的编辑器路径、终端、WSL 发行版等。
### ✅ 部署命令管理
- status: done
- files: src-tauri/src/commands/spaces.rs
每个空间可保存常用部署命令deploy commands

View File

@ -0,0 +1,35 @@
---
id: wsl-proxy
name: WSL 代理同步
status: done
progress: 100
---
## 概述
将 Windows 上的 v2rayN 代理配置同步到 WSL 环境,解决 WSL 下 curl/git/npm 等工具无法使用代理的问题。
## 任务卡
### ✅ 代理同步
- status: done
- files: src-tauri/src/commands/proxy.rs, src/components/proxy/ProxyPage.tsx
读取 v2rayN 配置,生成 WSL 的 proxy 环境变量脚本。
### ✅ 代理状态检测
- status: done
- files: src-tauri/src/commands/network.rs, src/components/proxy/ProxyPage.tsx
检测 WSL 中当前的代理配置状态是否已配置、IP 是否过期)。
### ✅ 代理清除
- status: done
- files: src-tauri/src/commands/proxy.rs
一键清除 WSL 中的代理配置。
### ✅ 代理一致性测试
- status: done
- files: src/components/proxy/ProxyPage.tsx
同时测试 Windows 和 WSL 的出口 IP验证代理是否生效且一致。

44
CLAUDE.md Normal file
View File

@ -0,0 +1,44 @@
# Dev Manager Tauri
Git 多空间开发管理器Tauri v2 + React + TypeScript + Rust。
## 项目蓝图
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
**每次对话都应遵循以下流程:**
### 1. 了解当前状态
对话开始时,先读取 `.blueprint/manifest.yaml` 了解项目模块和状态。
### 2. 讨论需求时更新蓝图
- 确认了新需求 → 在 manifest.yaml 新增模块,创建 `.blueprint/modules/<id>.md`
- 拆分了大需求 → 在模块文件中添加任务卡
- 调整了方向 → 更新模块关系edges
### 3. 完成实现后标记蓝图
- 完成了功能 → 更新对应任务卡状态为 `done`(前缀改为 ✅)
- 更新 manifest.yaml 顶部的 `updated` 日期
### 4. 任务卡规范
详见 `.blueprint/CONVENTIONS.md`,核心规则:
- 任务卡同时具备 `files` + `acceptance` + complexity `S/M` → 标记为 📋 可派发
- 可派发的任务卡可以直接交给 Claude Agent 独立完成
## 技术栈
- 前端React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow
- 后端Rust + Tauri v2 + SQLite (rusqlite + r2d2)
- 包管理pnpm禁止 npm/yarn
- CI/CDGitHub Actions + 阿里云 OSS详见 docs/tauri-cicd-guide.md
- 客户端更新tauri-plugin-updater详见 docs/tauri-updater-guide.md
## 目录结构
```
src/ 前端源码
src/components/ React 组件(按功能分目录)
src/lib/commands.ts Tauri command 调用封装
src-tauri/src/ Rust 后端
src-tauri/src/commands/ Tauri command 实现
.blueprint/ 项目蓝图manifest + 模块详情 + 任务卡)
docs/ 部署和更新指南
```

View File

@ -353,35 +353,11 @@ git push --tags
---
## 八、客户端更新检测(前端代码)
## 八、客户端自动更新
```typescript
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
客户端侧的完整实现(插件安装、权限配置、前端 UI 状态机、踩坑记录)已独立成文档:
async function checkForUpdate() {
const update = await check()
if (update) {
console.log(`发现新版本: ${update.version}`)
// 下载并安装
await update.downloadAndInstall()
// 重启应用
await relaunch()
}
}
```
需要在 `src-tauri/src/lib.rs` 中注册插件:
```rust
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
```
Cargo.toml 依赖:
```toml
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
```
**→ [tauri-updater-guide.md](./tauri-updater-guide.md)**
---

408
docs/tauri-updater-guide.md Normal file
View File

@ -0,0 +1,408 @@
# Tauri v2 客户端自动更新指南
> **方案版本**: v1.02026-04-02
> **适用范围**: Tauri v2 + 任意更新源(阿里云 OSS / GitHub Releases / 自建服务器)
> **验证状态**: 已在 dev-manager-tauri 项目实际跑通
>
> 本文档聚焦客户端侧的自动更新功能实现,涵盖从插件安装到 UI 交互的全链路。
> 服务端构建与发布流程请参考 [tauri-cicd-guide.md](./tauri-cicd-guide.md)。
---
## 整体流程
```
客户端启动 / 用户点击「检查更新」
请求 endpoint 上的 latest.json
比较 latest.json.version vs 本地版本
┌────┴────┐
│ 无更新 │ 有新版本
│ 结束 │
│ ▼
│ 展示版本号 + 更新说明
│ 用户点击「立即更新」
│ │
│ ▼
│ 下载 .exe带进度
│ │
│ ▼
│ 验证签名 → 安装 → relaunch
└─────────┘
```
---
## 一、安装依赖
### 1.1 Rust 端src-tauri/Cargo.toml
```toml
[dependencies]
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
```
### 1.2 前端package.json
```bash
pnpm add @tauri-apps/plugin-updater @tauri-apps/plugin-process
```
---
## 二、注册插件
`src-tauri/src/lib.rs` 中注册两个插件:
```rust
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// ... 其他插件和配置
```
- `tauri_plugin_updater` — 检查更新、下载、验证签名、安装
- `tauri_plugin_process` — 安装后调用 `relaunch()` 重启应用
---
## 三、配置 tauri.conf.json
```jsonc
{
"bundle": {
"createUpdaterArtifacts": true // ⚠️ 必须!否则构建不生成 .exe.sig 签名文件
},
"plugins": {
"updater": {
"pubkey": "<公钥 base64>",
"endpoints": [
"https://<bucket>.<endpoint>/latest.json"
],
"dialog": false // false = 用代码控制更新 UItrue = 系统原生弹窗
}
}
}
```
### 关键说明
| 字段 | 作用 | 注意事项 |
|------|------|---------|
| `createUpdaterArtifacts` | 构建时生成 `.exe.sig` 签名文件 | Tauri v2 默认 false不开启则签名静默跳过 |
| `pubkey` | 客户端用来验证下载文件签名 | 是**公钥文件**的 base64不是私钥 |
| `endpoints` | `latest.json` 的 URL 列表 | 可配多个,按顺序尝试 |
| `dialog` | 是否使用原生弹窗 | 推荐 false自定义 UI 体验更好 |
### pubkey 生成方法
```powershell
# 生成密钥对
pnpm tauri signer generate -w "$HOME\.tauri\your-app.key"
# 获取公钥 base64填入 pubkey 字段)
$content = Get-Content "$HOME\.tauri\your-app.key.pub" -Raw
$content = $content -replace "`r`n", "`n"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($content)
[Convert]::ToBase64String($bytes)
```
---
## 四、配置 Capabilities 权限
`src-tauri/capabilities/default.json` 中必须声明权限,否则前端 API 调用会被拦截:
```jsonc
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
// ... 其他权限 ...
{
"identifier": "http:default",
"allow": [
{ "url": "https://*.aliyuncs.com/**" } // ⚠️ 放行更新源域名
]
},
"updater:default", // ⚠️ 允许 check()、downloadAndInstall()
"process:default" // ⚠️ 允许 relaunch()
]
}
```
**三项缺一不可**
- `updater:default` — 否则报 `updater.check not allowed`
- `process:default` — 否则 `relaunch()` 无法调用
- http allow 列表包含更新源域名 — 否则报 `Could not fetch a valid release JSON`
---
## 五、latest.json 格式
更新源需要提供一个 `latest.json`,格式如下:
```json
{
"version": "0.1.11",
"pub_date": "2026-04-02T05:29:44Z",
"notes": "版本 v0.1.11 已发布",
"platforms": {
"windows-x86_64": {
"signature": "<.exe.sig 文件内容>",
"url": "https://<bucket>.<endpoint>/releases/v0.1.11/<app>_0.1.11_x64-setup.exe"
}
}
}
```
| 字段 | 说明 |
|------|------|
| `version` | 新版本号updater 用它和本地版本比较 |
| `pub_date` | ISO 8601 格式,必须有 |
| `notes` | 更新说明,前端通过 `update.body` 获取 |
| `platforms` | 按平台提供下载链接和签名 |
| `signature` | `.exe.sig` 文件内容base64客户端用 pubkey 验证 |
| `url` | 安装包下载地址,必须可公开访问 |
### 多平台支持
```json
{
"platforms": {
"windows-x86_64": { "signature": "...", "url": "...setup.exe" },
"darwin-x86_64": { "signature": "...", "url": "...app.tar.gz" },
"darwin-aarch64": { "signature": "...", "url": "...app.tar.gz" },
"linux-x86_64": { "signature": "...", "url": "...AppImage.tar.gz" }
}
}
```
---
## 六、前端实现React + TypeScript
### 6.1 状态机设计
更新流程有明确的状态流转,用联合类型建模最清晰:
```typescript
type UpdateState =
| { status: "idle" } // 初始状态
| { status: "checking" } // 正在检查
| { status: "latest" } // 已是最新
| { status: "available"; version: string; notes: string; downloadAndInstall: () => Promise<void> }
| { status: "downloading"; progress: number } // 下载中0~100
| { status: "error"; message: string } // 出错
```
### 6.2 完整组件参考
```typescript
import { useState, useEffect } from "react";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { getVersion } from "@tauri-apps/api/app";
function UpdateSection() {
const [appVersion, setAppVersion] = useState("");
const [state, setState] = useState<UpdateState>({ status: "idle" });
useEffect(() => {
getVersion().then(setAppVersion).catch(() => {});
}, []);
const handleCheck = async () => {
setState({ status: "checking" });
try {
const update = await checkUpdate();
if (!update?.available) {
setState({ status: "latest" });
return;
}
setState({
status: "available",
version: update.version,
notes: update.body ?? "",
downloadAndInstall: async () => {
let downloaded = 0;
let total = 0;
await update.downloadAndInstall((event) => {
if (event.event === "Started") {
total = event.data.contentLength ?? 0;
setState({ status: "downloading", progress: 0 });
} else if (event.event === "Progress") {
downloaded += event.data.chunkLength;
const progress = total > 0 ? Math.round((downloaded / total) * 100) : 0;
setState({ status: "downloading", progress });
} else if (event.event === "Finished") {
setState({ status: "idle" });
}
});
await relaunch();
},
});
} catch (e) {
setState({ status: "error", message: String(e) });
}
};
return (
<div>
<p>当前版本 v{appVersion}</p>
{state.status === "latest" && <span>已是最新版本</span>}
{state.status === "available" && (
<>
<span>v{state.version} 可用</span>
<button onClick={state.downloadAndInstall}>立即更新</button>
</>
)}
{state.status === "downloading" && (
<span>下载中 {state.progress}%</span>
)}
{state.status === "error" && (
<div>
<p>{state.message}</p>
<button onClick={() => navigator.clipboard.writeText(state.message)}>
复制错误
</button>
</div>
)}
{state.status !== "available" && (
<button
onClick={handleCheck}
disabled={state.status === "checking" || state.status === "downloading"}
>
{state.status === "checking" ? "检查中…" : "检查更新"}
</button>
)}
</div>
);
}
```
### 6.3 下载事件说明
`downloadAndInstall` 的回调事件顺序:
| 事件 | 数据 | 说明 |
|------|------|------|
| `Started` | `{ contentLength?: number }` | 开始下载contentLength 可能为 0 |
| `Progress` | `{ chunkLength: number }` | 每个数据块,累加计算进度 |
| `Finished` | 无 | 下载完成,随后自动安装 |
> `downloadAndInstall` 完成后立即调用 `relaunch()` 重启应用,新版本生效。
---
## 七、版本号管理
Tauri v2 项目有**三处版本号**必须保持一致:
| 文件 | 字段 | 作用 |
|------|------|------|
| `src-tauri/tauri.conf.json` | `"version"` | updater 用来和 latest.json 比较 |
| `src-tauri/Cargo.toml` | `version` | Rust 构建元数据 |
| `package.json` | `"version"` | 前端构建元数据 |
### 发版步骤
```bash
# 1. 三处改版本号(如 0.2.0
# 2. 提交
git add -A
git commit -m "release: v0.2.0"
# 3. 打 tag + pushPowerShell 不支持 &&,分开执行)
git tag v0.2.0
git push
git push --tags
```
> **版本号不一致的后果**`latest.json` 中的 version 高于本地版本时才会提示更新。
> 如果 `tauri.conf.json` 的版本没改,每次启动都会提示"有新版本"即使已经更新过。
---
## 八、更新源选择
| 更新源 | 优点 | 缺点 | 适用场景 |
|--------|------|------|---------|
| **阿里云 OSS** | 国内快CDN 加速 | 需要额外配置 OSS | 面向国内用户 |
| **GitHub Releases** | 零配置tauri-action 自动生成 | 国内访问慢/不稳定 | 面向海外用户 |
| **自建服务器** | 完全可控 | 需要运维 | 企业内网分发 |
Tauri v2 的 `endpoints` 支持多个 URL可以配置主备
```json
"endpoints": [
"https://<oss-bucket>.oss-cn-hangzhou.aliyuncs.com/latest.json",
"https://github.com/<user>/<repo>/releases/latest/download/latest.json"
]
```
---
## 九、调试技巧
### 9.1 验证 latest.json 可访问
```powershell
curl https://<your-endpoint>/latest.json
```
- 返回 JSON → 正常
- 返回 `AccessDenied` → OSS Bucket 未设为公共读
- 返回 404 → CI/CD 未上传或路径不对
### 9.2 开发模式测试
`pnpm tauri dev` 模式下也能调用 `check()`,但注意:
- 开发模式的版本号来自 `tauri.conf.json`
- 如果本地版本号和 latest.json 相同或更高,不会触发更新
### 9.3 强制触发更新(调试用)
临时把 `tauri.conf.json` 的版本号改低(如 `0.0.1`),再运行 `pnpm tauri dev` 点检查更新即可触发。
---
## 十、踩坑清单
| # | 问题 | 现象 | 解决方案 |
|---|------|------|---------|
| 1 | 缺少 `updater:default` | `updater.check not allowed` | `capabilities/default.json` 添加 `updater:default` |
| 2 | 缺少 `process:default` | `relaunch()` 无效 | `capabilities/default.json` 添加 `process:default` |
| 3 | http 未放行更新源域名 | `Could not fetch a valid release JSON` | capabilities http allow 加更新源域名 |
| 4 | OSS Bucket 为私有 | 同上curl 返回 `AccessDenied` | Bucket ACL 改为**公共读** |
| 5 | `createUpdaterArtifacts` 未开启 | 构建成功但无 `.exe.sig` | `tauri.conf.json``bundle.createUpdaterArtifacts: true` |
| 6 | pubkey 填了私钥 | `failed to decode pubkey` | 用公钥文件的 base64 |
| 7 | 版本号三处不一致 | 更新后仍提示有新版本 | `tauri.conf.json`、`Cargo.toml`、`package.json` 必须同步 |
| 8 | latest.json 缺少 `pub_date` | updater 解析失败 | 必须包含 ISO 8601 格式的日期 |
| 9 | signature 字段为空 | 签名验证失败 | 检查 CI/CD 是否正确读取了 `.exe.sig` 文件内容 |
| 10 | `dialog: true` 时无法自定义 UI | 弹出系统原生对话框 | 设为 `false`,用代码实现自定义更新界面 |
---
## 配置文件速查
新项目最小配置清单:
```
src-tauri/Cargo.toml → 添加 tauri-plugin-updater、tauri-plugin-process 依赖
package.json → pnpm add @tauri-apps/plugin-updater @tauri-apps/plugin-process
src-tauri/src/lib.rs → 注册两个插件
src-tauri/tauri.conf.json → bundle.createUpdaterArtifacts + plugins.updater 配置
src-tauri/capabilities/*.json → updater:default + process:default + http allow
前端组件 → 调用 check() + downloadAndInstall() + relaunch()
```

View File

@ -18,6 +18,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.0",
"@xyflow/react": "^12.10.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.12"

189
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
'@tauri-apps/plugin-updater':
specifier: ^2.10.0
version: 2.10.0
'@xyflow/react':
specifier: ^12.10.2
version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.1.0
version: 19.2.4
@ -40,7 +43,7 @@ importers:
version: 19.2.4(react@19.2.4)
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.2
@ -654,6 +657,24 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -671,6 +692,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@xyflow/react@12.10.2':
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@xyflow/system@0.0.76':
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
baseline-browser-mapping@2.10.10:
resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==}
engines: {node: '>=6.0.0'}
@ -684,12 +714,53 @@ packages:
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -906,6 +977,11 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -949,6 +1025,21 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
@ -1424,6 +1515,27 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-selection@3.0.11': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/estree@1.0.8': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)':
@ -1446,6 +1558,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@xyflow/system': 0.0.76
classcat: 5.0.5
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- immer
'@xyflow/system@0.0.76':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
baseline-browser-mapping@2.10.10: {}
browserslist@4.28.1:
@ -1458,10 +1593,48 @@ snapshots:
caniuse-lite@1.0.30001781: {}
classcat@5.0.5: {}
convert-source-map@2.0.0: {}
csstype@3.2.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
debug@4.4.3:
dependencies:
ms: 2.1.3
@ -1661,6 +1834,10 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0):
dependencies:
esbuild: 0.27.4
@ -1676,7 +1853,15 @@ snapshots:
yallist@3.1.1: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
zustand@4.5.7(@types/react@19.2.14)(react@19.2.4):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.4
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)

22
src-tauri/Cargo.lock generated
View File

@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "dev-manager-tauri"
version = "0.1.10"
version = "0.1.11"
dependencies = [
"base64 0.22.1",
"chrono",
@ -837,6 +837,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@ -4121,6 +4122,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@ -5381,6 +5395,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"

View File

@ -38,4 +38,5 @@ ureq = { version = "2", features = ["json"] }
open = "5"
base64 = "0.22"
urlencoding = "2"
serde_yaml = "0.9"

View File

@ -0,0 +1,299 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
// ── manifest.yaml 对应结构 ───────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintManifest {
pub version: u32,
pub name: String,
pub description: Option<String>,
pub updated: Option<String>,
#[serde(default)]
pub areas: Vec<BlueprintArea>,
#[serde(default)]
pub modules: Vec<BlueprintModule>,
#[serde(default)]
pub edges: Vec<BlueprintEdge>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintArea {
pub id: String,
pub name: String,
pub color: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintModule {
pub id: String,
pub name: String,
pub area: String,
pub status: String,
#[serde(default)]
pub progress: u32,
#[serde(default)]
pub position: Vec<f64>,
// 运行时填充:从 modules/<id>.md 解析
#[serde(skip_deserializing, default)]
pub description: Option<String>,
#[serde(skip_deserializing, default)]
pub decisions: Vec<String>,
#[serde(skip_deserializing, default)]
pub tasks: Vec<BlueprintTask>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintEdge {
pub from: String,
pub to: String,
#[serde(rename = "type")]
pub edge_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintTask {
pub title: String,
pub status: String,
pub complexity: Option<String>,
pub files: Option<String>,
pub depends: Option<String>,
pub acceptance: Option<String>,
pub notes: Option<String>,
}
// ── 完整蓝图返回结构 ─────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Clone)]
pub struct BlueprintData {
pub manifest: BlueprintManifest,
pub stats: BlueprintStats,
}
#[derive(Debug, Serialize, Clone)]
pub struct BlueprintStats {
pub total_modules: u32,
pub done: u32,
pub in_progress: u32,
pub planned: u32,
pub concept: u32,
pub total_tasks: u32,
pub tasks_done: u32,
pub dispatchable: u32,
}
// ── tauri command ────────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_blueprint(project_path: String) -> Result<Option<BlueprintData>, String> {
let bp_dir = Path::new(&project_path).join(".blueprint");
let manifest_path = bp_dir.join("manifest.yaml");
if !manifest_path.exists() {
return Ok(None);
}
let yaml_str = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("读取 manifest.yaml 失败: {e}"))?;
let mut manifest: BlueprintManifest = serde_yaml::from_str(&yaml_str)
.map_err(|e| format!("解析 manifest.yaml 失败: {e}"))?;
// 读取每个模块的 markdown 文件
let modules_dir = bp_dir.join("modules");
for module in &mut manifest.modules {
let md_path = modules_dir.join(format!("{}.md", module.id));
if md_path.exists() {
let content = std::fs::read_to_string(&md_path).unwrap_or_default();
let parsed = parse_module_md(&content);
module.description = parsed.description;
module.decisions = parsed.decisions;
module.tasks = parsed.tasks;
// 自动计算 progress有任务卡时按完成比例无任务卡保留 manifest 中的值
if !module.tasks.is_empty() {
let done_count = module.tasks.iter().filter(|t| t.status == "done").count();
module.progress = ((done_count as f64 / module.tasks.len() as f64) * 100.0).round() as u32;
}
}
}
// 统计
let stats = compute_stats(&manifest);
Ok(Some(BlueprintData { manifest, stats }))
}
// ── markdown 解析 ────────────────────────────────────────────────────────────
struct ParsedModule {
description: Option<String>,
decisions: Vec<String>,
tasks: Vec<BlueprintTask>,
}
fn parse_module_md(content: &str) -> ParsedModule {
let mut tasks = Vec::new();
let mut description = None;
let mut decisions: Vec<String> = Vec::new();
let body = skip_frontmatter(content);
#[derive(PartialEq)]
enum Section { None, Overview, Decisions, Tasks }
let mut section = Section::None;
let mut overview_lines: Vec<&str> = Vec::new();
let mut current_task: Option<BlueprintTask> = None;
for line in body.lines() {
// 二级标题切换段落
if line.starts_with("## ") {
if let Some(task) = current_task.take() {
tasks.push(task);
}
let heading = line.trim_start_matches("## ").trim();
section = match heading {
"概述" => Section::Overview,
"决策记录" => Section::Decisions,
"任务卡" => Section::Tasks,
_ => Section::None,
};
continue;
}
// 三级标题 = 任务卡(仅在任务卡段落内)
if line.starts_with("### ") && (section == Section::Tasks || section == Section::None) {
section = Section::Tasks;
if let Some(task) = current_task.take() {
tasks.push(task);
}
let title_raw = line.trim_start_matches("### ").trim();
let (status, title) = parse_task_title(title_raw);
current_task = Some(BlueprintTask {
title: title.to_string(),
status,
complexity: None,
files: None,
depends: None,
acceptance: None,
notes: None,
});
continue;
}
match section {
Section::Overview => {
let trimmed = line.trim();
if !trimmed.is_empty() {
overview_lines.push(trimmed);
}
}
Section::Decisions => {
let trimmed = line.trim();
// 收集 "- xxx" 格式的决策条目
if let Some(item) = trimmed.strip_prefix("- ") {
if !item.is_empty() {
decisions.push(item.to_string());
}
}
}
Section::Tasks => {
if let Some(ref mut task) = current_task {
let trimmed = line.trim().trim_start_matches("- ");
if let Some(val) = trimmed.strip_prefix("status: ") {
task.status = val.trim().to_string();
} else if let Some(val) = trimmed.strip_prefix("complexity: ") {
task.complexity = Some(val.trim().to_string());
} else if let Some(val) = trimmed.strip_prefix("files: ") {
task.files = Some(val.trim().to_string());
} else if let Some(val) = trimmed.strip_prefix("depends: ") {
task.depends = Some(val.trim().to_string());
} else if let Some(val) = trimmed.strip_prefix("acceptance: ") {
task.acceptance = Some(val.trim().to_string());
} else if let Some(val) = trimmed.strip_prefix("notes: ") {
task.notes = Some(val.trim().to_string());
}
}
}
Section::None => {}
}
}
if let Some(task) = current_task {
tasks.push(task);
}
if !overview_lines.is_empty() {
description = Some(overview_lines.join(" "));
}
ParsedModule { description, decisions, tasks }
}
fn skip_frontmatter(content: &str) -> &str {
if content.starts_with("---") {
if let Some(end) = content[3..].find("\n---") {
let skip = 3 + end + 4; // "---" + content + "\n---"
return content.get(skip..).unwrap_or("").trim_start_matches('\n');
}
}
content
}
fn parse_task_title(raw: &str) -> (String, &str) {
// "✅ 基础操作" → ("done", "基础操作")
if let Some(rest) = raw.strip_prefix("") {
return ("done".to_string(), rest);
}
if let Some(rest) = raw.strip_prefix("🔵 ") {
return ("in_progress".to_string(), rest);
}
if let Some(rest) = raw.strip_prefix("📋 ") {
return ("todo".to_string(), rest);
}
if let Some(rest) = raw.strip_prefix("💭 ") {
return ("concept".to_string(), rest);
}
("todo".to_string(), raw)
}
fn compute_stats(manifest: &BlueprintManifest) -> BlueprintStats {
let mut stats = BlueprintStats {
total_modules: manifest.modules.len() as u32,
done: 0,
in_progress: 0,
planned: 0,
concept: 0,
total_tasks: 0,
tasks_done: 0,
dispatchable: 0,
};
for m in &manifest.modules {
match m.status.as_str() {
"done" => stats.done += 1,
"in_progress" => stats.in_progress += 1,
"planned" => stats.planned += 1,
"concept" => stats.concept += 1,
_ => {}
}
for t in &m.tasks {
stats.total_tasks += 1;
if t.status == "done" {
stats.tasks_done += 1;
}
// 可派发todo + 有 files + 有 acceptance + complexity S 或 M
if t.status == "todo"
&& t.files.is_some()
&& t.acceptance.is_some()
&& matches!(
t.complexity.as_deref(),
Some("S") | Some("M")
)
{
stats.dispatchable += 1;
}
}
}
stats
}

View File

@ -0,0 +1,30 @@
use crate::db::pool;
use rusqlite::params;
/// 读取产品组的画布状态JSON 字符串),不存在时返回 null
#[tauri::command]
pub fn get_canvas_state(group_id: String) -> Result<Option<String>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
match conn.query_row(
"SELECT data FROM canvas_states WHERE group_id = ?1",
params![group_id],
|row| row.get::<_, String>(0),
) {
Ok(data) => Ok(Some(data)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
/// 保存(覆盖)产品组的画布状态
#[tauri::command]
pub fn save_canvas_state(group_id: String, data: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO canvas_states (group_id, data, updated_at)
VALUES (?1, ?2, datetime('now'))",
params![group_id, data],
)
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -16,3 +16,5 @@ pub mod publish;
pub mod templates;
pub mod tags;
pub mod cicd;
pub mod blueprint;
pub mod canvas;

View File

@ -379,4 +379,13 @@ fn migrate(conn: &rusqlite::Connection) {
created_at TEXT DEFAULT (datetime('now'))
);
").expect("create cicd_configs table");
// 资源视角画布状态(每个产品组独立保存)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS canvas_states (
group_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create canvas_states table");
}

View File

@ -3,7 +3,7 @@ mod db;
mod models;
use git2;
use commands::{activity::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{detect_editors, get_settings, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{detect_editors, get_settings, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@ -148,6 +148,11 @@ pub fn run() {
get_cicd_config,
save_cicd_config,
generate_workflow,
// blueprint
get_blueprint,
// canvas state
get_canvas_state,
save_canvas_state,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -0,0 +1,656 @@
import { useState, useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ReactFlow,
Background,
Controls,
type Node,
type Edge,
type NodeTypes,
Handle,
Position,
MarkerType,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
getBlueprint,
type BlueprintData,
type BlueprintModule,
type BlueprintTask,
type BlueprintArea,
} from "../../lib/commands";
interface Props {
projectName: string;
projectPath: string;
onClose: () => void;
}
// ── 状态色标 ─────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
done: { color: "#22C55E", bg: "#F0FDF4", label: "已完成" },
in_progress: { color: "#3B82F6", bg: "#EFF6FF", label: "进行中" },
planned: { color: "#EAB308", bg: "#FEFCE8", label: "已规划" },
concept: { color: "#9CA3AF", bg: "#F9FAFB", label: "构思中" },
};
const TASK_PREFIX: Record<string, string> = {
done: "✅",
in_progress: "🔵",
todo: "📋",
concept: "💭",
};
// ── 领域分组节点 ─────────────────────────────────────────────────────────────
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"
style={{
borderColor: data.color + "60",
background: data.color + "08",
}}
>
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-1 rounded-full" style={{ background: data.color }} />
<span className="text-[11px] font-bold uppercase tracking-wider" style={{ color: data.color }}>
{data.label}
</span>
</div>
</div>
);
}
// ── 模块节点 ─────────────────────────────────────────────────────────────────
function ModuleNode({ data }: { data: BlueprintModule & { areaColor?: string; onClick: () => 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;
return (
<div
onClick={data.onClick}
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,
borderLeftWidth: 4,
borderLeftColor: data.areaColor ?? cfg.color,
}}
>
<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 }} />
<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 }} />
<span className="text-xs font-semibold text-gray-800 truncate">{data.name}</span>
</div>
{/* 进度条 */}
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{ width: `${data.progress}%`, background: cfg.color }}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500">{cfg.label}</span>
{tasksTotal > 0 && (
<span className="text-[10px] text-gray-400">{tasksDone}/{tasksTotal}</span>
)}
</div>
</div>
);
}
const nodeTypes: NodeTypes = {
module: ModuleNode,
area: AreaNode,
};
// ── 布局计算 ─────────────────────────────────────────────────────────────────
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 COLS_PER_AREA = 4;
function autoLayout(
areas: BlueprintArea[],
modules: BlueprintModule[],
) {
const areaNodes: Node[] = [];
const moduleNodes: Node[] = [];
let areaOffsetY = 0;
for (const area of areas) {
const areaModules = modules.filter((m) => m.area === area.id);
if (areaModules.length === 0) continue;
const cols = Math.min(areaModules.length, COLS_PER_AREA);
const rows = Math.ceil(areaModules.length / cols);
const areaW = cols * (NODE_W + GAP_X) - GAP_X + AREA_PAD_X * 2;
const areaH = rows * (NODE_H + GAP_Y) - GAP_Y + AREA_PAD_Y + AREA_PAD_X;
areaNodes.push({
id: `area-${area.id}`,
type: "area",
position: { x: 0, y: areaOffsetY },
data: { label: area.name, color: area.color ?? "#6B7280" },
style: { width: areaW, height: areaH },
selectable: false,
draggable: false,
});
areaModules.forEach((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
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),
},
parentId: `area-${area.id}`,
extent: "parent" as const,
data: { ...m, areaColor: area.color },
});
});
areaOffsetY += areaH + AREA_GAP;
}
return { areaNodes, moduleNodes };
}
// ── 主组件 ───────────────────────────────────────────────────────────────────
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | null;
export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
const [sidePanel, setSidePanel] = useState<SidePanel>(null);
const { data: blueprint, isLoading } = useQuery<BlueprintData | null>({
queryKey: ["blueprint", projectPath],
queryFn: () => getBlueprint(projectPath),
});
const handleNodeClick = useCallback((mod: BlueprintModule) => {
setSidePanel({ type: "module", module: mod });
}, []);
const { nodes, edges } = useMemo(() => {
if (!blueprint) return { nodes: [] as Node[], edges: [] as Edge[] };
const { areaNodes, moduleNodes } = autoLayout(
blueprint.manifest.areas,
blueprint.manifest.modules,
);
// 注入 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) };
}
}
const flowNodes: Node[] = [...areaNodes, ...moduleNodes];
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,
},
}));
return { nodes: flowNodes, edges: flowEdges };
}, [blueprint, handleNodeClick]);
const stats = blueprint?.stats;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<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">
<div>
<h2 className="text-sm font-semibold text-gray-800"></h2>
<p className="text-xs text-gray-400 mt-0.5">
{projectName}
{blueprint?.manifest.updated && (
<span className="ml-2 text-gray-300"> {blueprint.manifest.updated}</span>
)}
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">
×
</button>
</div>
{/* 统计条 */}
{stats && (
<div className="px-5 py-2.5 border-b border-gray-100 flex items-center gap-4 shrink-0 flex-wrap">
<StatBadge color="#22C55E" label="已完成" count={stats.done} />
<StatBadge color="#3B82F6" label="进行中" count={stats.in_progress} />
<StatBadge color="#EAB308" label="已规划" count={stats.planned} />
<StatBadge color="#9CA3AF" label="构思中" count={stats.concept} />
<div className="h-4 w-px bg-gray-200" />
<span className="text-xs text-gray-500">
{stats.tasks_done}/{stats.total_tasks}
</span>
{stats.dispatchable > 0 && (
<button
onClick={() => setSidePanel((p) => p?.type === "next" ? null : { type: "next" })}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
sidePanel?.type === "next"
? "text-white bg-blue-600"
: "text-blue-600 bg-blue-50 hover:bg-blue-100"
}`}
>
📋 {stats.dispatchable}
</button>
)}
{/* 总进度条 */}
<div className="flex-1 flex items-center gap-2 ml-2 min-w-[120px]">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${stats.total_modules > 0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%` }}
/>
</div>
<span className="text-xs text-gray-400 shrink-0">
{stats.total_modules > 0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%
</span>
</div>
</div>
)}
{/* 图例 */}
{blueprint && (
<div className="px-5 py-1.5 border-b border-gray-50 flex items-center gap-5 shrink-0">
<span className="text-[10px] text-gray-400">:</span>
<div className="flex items-center gap-1.5">
<svg width="24" height="8"><line x1="0" y1="4" x2="24" y2="4" stroke="#3B82F6" strokeWidth="2" /><polygon points="20,1 24,4 20,7" fill="#3B82F6" /></svg>
<span className="text-[10px] text-gray-400"></span>
</div>
<div className="flex items-center gap-1.5">
<svg width="24" height="8"><line x1="0" y1="4" x2="24" y2="4" stroke="#D1D5DB" strokeWidth="1" strokeDasharray="4 3" /><polygon points="20,1 24,4 20,7" fill="#D1D5DB" /></svg>
<span className="text-[10px] text-gray-400"></span>
</div>
<div className="h-3 w-px bg-gray-200" />
{blueprint.manifest.areas.map((a) => (
<div key={a.id} className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded border" style={{ background: (a.color ?? "#6B7280") + "20", borderColor: (a.color ?? "#6B7280") + "60" }} />
<span className="text-[10px] text-gray-400">{a.name}</span>
</div>
))}
</div>
)}
{/* 主体区域 */}
<div className="flex-1 flex min-h-0">
{/* React Flow 画布 */}
<div className="flex-1 relative">
{isLoading ? (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
...
</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>
<p className="text-gray-400 text-sm"></p>
<p className="text-gray-300 text-xs">
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-gray-500">.blueprint/</code>
</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
>
<Background gap={20} size={1} color="#f0f0f0" />
<Controls position="bottom-left" showInteractive={false} />
</ReactFlow>
)}
</div>
{/* 侧边面板 */}
{sidePanel && (
<div className="w-[340px] border-l border-gray-200 overflow-y-auto shrink-0 bg-gray-50/50">
{sidePanel.type === "module" ? (
<ModuleDetail
module={sidePanel.module}
areas={blueprint?.manifest.areas ?? []}
onClose={() => setSidePanel(null)}
/>
) : (
<NextActionsPanel
modules={blueprint?.manifest.modules ?? []}
onSelectModule={(mod) => setSidePanel({ type: "module", module: mod })}
onClose={() => setSidePanel(null)}
/>
)}
</div>
)}
</div>
</div>
</div>
);
}
// ── 统计小标签 ───────────────────────────────────────────────────────────────
function StatBadge({ color, label, count }: { color: string; label: string; count: number }) {
return (
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
<span className="text-xs text-gray-500">{label}</span>
<span className="text-xs font-semibold text-gray-700">{count}</span>
</div>
);
}
// ── 模块详情面板 ─────────────────────────────────────────────────────────────
function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule; areas: BlueprintArea[]; onClose: () => void }) {
const cfg = STATUS_CONFIG[mod.status] ?? STATUS_CONFIG.concept;
const area = areas.find((a) => a.id === mod.area);
const grouped = useMemo(() => {
const groups: Record<string, BlueprintTask[]> = {
done: [],
in_progress: [],
todo: [],
concept: [],
};
for (const t of mod.tasks) {
const key = groups[t.status] ? t.status : "concept";
groups[key].push(t);
}
return groups;
}, [mod.tasks]);
const groupLabels: Record<string, string> = {
done: "已完成",
in_progress: "进行中",
todo: "待开发",
concept: "构思中",
};
return (
<div className="p-4 space-y-4">
{/* 标题 */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ background: cfg.color }} />
<h3 className="text-sm font-semibold text-gray-800">{mod.name}</h3>
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">{cfg.label} · {mod.progress}%</span>
{area && (
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ background: (area.color ?? "#6B7280") + "15", color: area.color ?? "#6B7280" }}
>
{area.name}
</span>
)}
</div>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{/* 进度条 */}
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${mod.progress}%`, background: cfg.color }} />
</div>
{/* 描述 */}
{mod.description && (
<p className="text-xs text-gray-500 leading-relaxed bg-white rounded-lg px-3 py-2 border border-gray-100">
{mod.description}
</p>
)}
{/* 决策记录 */}
{mod.decisions && mod.decisions.length > 0 && (
<div className="bg-amber-50 rounded-lg px-3 py-2 border border-amber-100">
<p className="text-[10px] font-semibold text-amber-600 uppercase tracking-wider mb-1"></p>
<ul className="space-y-1">
{mod.decisions.map((d, i) => (
<li key={i} className="text-xs text-amber-700 flex gap-1.5">
<span className="shrink-0 text-amber-400">·</span>
<span>{d}</span>
</li>
))}
</ul>
</div>
)}
{/* 任务卡列表 */}
{mod.tasks.length > 0 && (
<div className="space-y-3">
{(["in_progress", "todo", "concept", "done"] as const).map((status) => {
const tasks = grouped[status];
if (!tasks || tasks.length === 0) return null;
return (
<div key={status}>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">
{groupLabels[status]} ({tasks.length})
</p>
<div className="space-y-1.5">
{tasks.map((t, i) => (
<TaskCard key={i} task={t} moduleName={mod.name} />
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 任务卡 ───────────────────────────────────────────────────────────────────
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 isDispatchable =
task.status === "todo" &&
task.files &&
task.acceptance &&
(task.complexity === "S" || task.complexity === "M");
const handleCopyForAgent = (e: React.MouseEvent) => {
e.stopPropagation();
const lines = [
`请完成以下任务(完成后更新 .blueprint/modules/ 中对应的任务卡状态为 ✅ done`,
``,
`## ${task.title}`,
moduleName ? `所属模块: ${moduleName}` : "",
task.complexity ? `复杂度: ${task.complexity}` : "",
``,
task.files ? `### 涉及文件\n${task.files}` : "",
task.acceptance ? `\n### 验收标准\n${task.acceptance}` : "",
task.depends ? `\n### 前置依赖\n${task.depends}` : "",
task.notes ? `\n### 补充说明\n${task.notes}` : "",
].filter(Boolean);
navigator.clipboard.writeText(lines.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<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"
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs shrink-0">{prefix}</span>
<span className="text-xs text-gray-700 flex-1">{task.title}</span>
{task.complexity && (
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded shrink-0">
{task.complexity}
</span>
)}
{isDispatchable && (
<button
onClick={handleCopyForAgent}
className="text-[10px] text-blue-500 bg-blue-100 px-1.5 py-0.5 rounded shrink-0 hover:bg-blue-200 transition-colors"
>
{copied ? "已复制" : "复制派发"}
</button>
)}
</div>
{expanded && (
<div className="mt-2 pt-2 border-t border-gray-100 space-y-1 text-[11px] text-gray-500">
{task.files && (
<p><span className="text-gray-400">:</span> <code className="font-mono text-gray-600 bg-gray-50 px-1 rounded">{task.files}</code></p>
)}
{task.acceptance && (
<p><span className="text-gray-400">:</span> {task.acceptance}</p>
)}
{task.depends && (
<p><span className="text-gray-400">:</span> {task.depends}</p>
)}
{task.notes && (
<p><span className="text-gray-400">:</span> {task.notes}</p>
)}
</div>
)}
</div>
);
}
// ── 下一步汇总面板 ──────────────────────────────────────────────────────────
function NextActionsPanel({
modules,
onSelectModule,
onClose,
}: {
modules: BlueprintModule[];
onSelectModule: (mod: BlueprintModule) => void;
onClose: () => void;
}) {
// 收集所有可派发和进行中的任务
const actionItems = useMemo(() => {
const items: { module: BlueprintModule; task: BlueprintTask; dispatchable: boolean }[] = [];
for (const mod of modules) {
for (const t of mod.tasks) {
const dispatchable =
t.status === "todo" &&
!!t.files &&
!!t.acceptance &&
(t.complexity === "S" || t.complexity === "M");
if (dispatchable || t.status === "in_progress") {
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;
});
return items;
}, [modules]);
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"></h3>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{actionItems.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-6"></p>
) : (
<div className="space-y-3">
{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>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.task.status === "in_progress")
.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>
)}
{actionItems.filter((i) => i.dispatchable).length > 0 && (
<div>
<p className="text-[10px] font-semibold text-amber-500 uppercase tracking-wider mb-1.5"></p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.dispatchable)
.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

@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, type ProductGroup, type Project } from "../../lib/commands";
import { ProjectCard } from "./ProjectCard";
import { DeployModal } from "./DeployModal";
import { GroupResourceCanvas } from "./GroupResourceCanvas";
import { ManagePage } from "../manage/ManagePage";
import { BatchAddModal } from "./BatchAddModal";
import { MountRepoModal } from "./MountRepoModal";
@ -14,6 +15,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
type DashView = "board" | "manage";
type BoardTab = "groups" | "standalone";
type GroupView = "default" | "resource";
interface Props {
spacesJson: SpacesJson | null;
@ -216,6 +218,7 @@ interface GroupBoardSectionProps {
function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionProps) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [groupView, setGroupView] = useState<GroupView>("default");
const winMembers = members.filter(({ project: p }) => p.platform !== "wsl");
const wslMembers = members.filter(({ project: p }) => p.platform === "wsl");
@ -231,8 +234,39 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
<span className="ml-auto text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2.5 py-0.5">
{members.length}
</span>
{/* 视图切换 */}
<div className="flex items-center rounded-lg border border-gray-200 overflow-hidden text-[11px] font-medium">
<button
onClick={() => setGroupView("default")}
className={`px-2.5 py-1 transition-colors ${
groupView === "default"
? "bg-gray-700 text-white"
: "bg-white text-gray-500 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setGroupView("resource")}
className={`px-2.5 py-1 transition-colors border-l border-gray-200 ${
groupView === "resource"
? "bg-gray-700 text-white"
: "bg-white text-gray-500 hover:bg-gray-50"
}`}
>
</button>
</div>
</div>
{/* ── 资源视角画布 ── */}
{groupView === "resource" && (
<GroupResourceCanvas members={members} groupId={g.id} />
)}
{/* ── 默认视图 ── */}
{groupView === "default" && (
<>
{/* 列头 */}
<div className="flex border-b border-gray-100 bg-gray-50/60">
<div className="flex-1 px-5 py-2 text-xs font-semibold text-gray-400"></div>
@ -277,6 +311,8 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
: <p className="text-xs text-gray-300 py-5 text-center"></p>
}
</div>
</>
)}
{deployTarget && (
<DeployModal

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ import { ProjectToolsModal } from "./ProjectToolsModal";
import { ProjectEnvModal } from "./ProjectEnvModal";
import { CicdModal } from "./CicdModal";
import { PublishModal } from "./PublishModal";
import { BlueprintModal } from "./BlueprintModal";
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener";
@ -22,6 +23,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
const [showEnvScan, setShowEnvScan] = useState(false);
const [showCicd, setShowCicd] = useState(false);
const [showPublish, setShowPublish] = useState(false);
const [showBlueprint, setShowBlueprint] = useState(false);
const [showBranchMenu, setShowBranchMenu] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [creatingBranch, setCreatingBranch] = useState(false);
@ -622,6 +624,10 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center justify-between">
<span className="text-[11px] text-gray-300">{(p.updated_at ?? "").slice(0, 10)}</span>
<div className="flex items-center gap-1.5">
<button onClick={() => setShowBlueprint(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="项目蓝图">
🗺
</button>
<button onClick={() => setShowCicd(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="CI/CD 控制台">
🚀
@ -687,6 +693,14 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
/>
)}
{showBlueprint && (p.win_path || p.wsl_path) && (
<BlueprintModal
projectName={p.name}
projectPath={p.win_path ?? p.wsl_path ?? ""}
onClose={() => setShowBlueprint(false)}
/>
)}
{/* Expanded activity */}
{expanded && (
<div className="border-t border-gray-50 pt-3">

View File

@ -849,3 +849,74 @@ export function buildShareLink(node: V2raynNode): string {
return "";
}
// ── Blueprint ──────────────────────────────────────────────────────────────
export interface BlueprintTask {
title: string;
status: string;
complexity?: string;
files?: string;
depends?: string;
acceptance?: string;
notes?: string;
}
export interface BlueprintModule {
id: string;
name: string;
area: string;
status: string;
progress: number;
position: number[];
description?: string;
decisions: string[];
tasks: BlueprintTask[];
}
export interface BlueprintEdge {
from: string;
to: string;
edge_type: string;
}
export interface BlueprintArea {
id: string;
name: string;
color?: string;
}
export interface BlueprintStats {
total_modules: number;
done: number;
in_progress: number;
planned: number;
concept: number;
total_tasks: number;
tasks_done: number;
dispatchable: number;
}
export interface BlueprintData {
manifest: {
version: number;
name: string;
description?: string;
updated?: string;
areas: BlueprintArea[];
modules: BlueprintModule[];
edges: BlueprintEdge[];
};
stats: BlueprintStats;
}
export const getBlueprint = (projectPath: string) =>
invoke<BlueprintData | null>("get_blueprint", { projectPath });
// ── Canvas State ───────────────────────────────────────────────────────────────
export const getCanvasState = (groupId: string) =>
invoke<string | null>("get_canvas_state", { groupId });
export const saveCanvasState = (groupId: string, data: string) =>
invoke<void>("save_canvas_state", { groupId, data });