diff --git a/.blueprint/CONVENTIONS.md b/.blueprint/CONVENTIONS.md new file mode 100644 index 0000000..c3b7dd7 --- /dev/null +++ b/.blueprint/CONVENTIONS.md @@ -0,0 +1,163 @@ +# 蓝图更新规则 + +本文件定义了 `.blueprint/` 目录的维护规范,供 Claude 在对话中遵循。 + +## 目录结构 + +``` +.blueprint/ +├── manifest.yaml ← 模块列表、关系、布局坐标 +├── CONVENTIONS.md ← 本文件(Claude 行为规则) +└── modules/ + └── .md ← 每个模块的详情 + 任务卡 +``` + +## 何时更新蓝图 + +| 事件 | 操作 | +|------|------| +| 用户确认了新需求 | 在 manifest.yaml 新增模块,创建 modules/.md | +| 完成了功能实现 | 更新模块 status → done,标记任务卡 ✅ | +| 拆分了大需求为子任务 | 在 modules/.md 中添加任务卡 | +| 调整了架构方向 | 更新 edges 关系,可能调整 area | +| 放弃了某个方向 | 删除对应模块和文件,或标记 status: abandoned | + +## 模块状态定义 + +| 状态 | 含义 | 色标 | +|------|------|------| +| `done` | 已实现并验证 | 🟢 绿色 | +| `in_progress` | 正在开发 | 🔵 蓝色 | +| `planned` | 已规划,需求明确 | 🟡 黄色 | +| `concept` | 构思中,信息不完整 | ⚪ 灰色 | + +## 任务卡格式 + +modules/.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~100,done 自动视为 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/.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 应主动提议更新(而非沉默) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml new file mode 100644 index 0000000..75d83df --- /dev/null +++ b/.blueprint/manifest.yaml @@ -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 diff --git a/.blueprint/modules/auto-update.md b/.blueprint/modules/auto-update.md new file mode 100644 index 0000000..c7ee862 --- /dev/null +++ b/.blueprint/modules/auto-update.md @@ -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: 应用启动时自动检查,发现新版本后在状态栏提示,而非需要手动进入设置页。 diff --git a/.blueprint/modules/blueprint-reader.md b/.blueprint/modules/blueprint-reader.md new file mode 100644 index 0000000..44e3f06 --- /dev/null +++ b/.blueprint/modules/blueprint-reader.md @@ -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 + 所有模块详情 + 任务卡),前端无需多次请求 diff --git a/.blueprint/modules/blueprint-view.md b/.blueprint/modules/blueprint-view.md new file mode 100644 index 0000000..8b8eba4 --- /dev/null +++ b/.blueprint/modules/blueprint-view.md @@ -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) diff --git a/.blueprint/modules/cicd-workflow.md b/.blueprint/modules/cicd-workflow.md new file mode 100644 index 0000000..61ad550 --- /dev/null +++ b/.blueprint/modules/cicd-workflow.md @@ -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 运行状态,支持手动触发。 diff --git a/.blueprint/modules/devtools-scan.md b/.blueprint/modules/devtools-scan.md new file mode 100644 index 0000000..d560fef --- /dev/null +++ b/.blueprint/modules/devtools-scan.md @@ -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 + +保存、更新、删除扫描到的工具信息。 diff --git a/.blueprint/modules/git-ops.md b/.blueprint/modules/git-ops.md new file mode 100644 index 0000000..558f8ae --- /dev/null +++ b/.blueprint/modules/git-ops.md @@ -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 面板。 diff --git a/.blueprint/modules/github-auth.md b/.blueprint/modules/github-auth.md new file mode 100644 index 0000000..60deedb --- /dev/null +++ b/.blueprint/modules/github-auth.md @@ -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,支持登出。 diff --git a/.blueprint/modules/github-publish.md b/.blueprint/modules/github-publish.md new file mode 100644 index 0000000..af4e969 --- /dev/null +++ b/.blueprint/modules/github-publish.md @@ -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 文件。 diff --git a/.blueprint/modules/project-dashboard.md b/.blueprint/modules/project-dashboard.md new file mode 100644 index 0000000..720a146 --- /dev/null +++ b/.blueprint/modules/project-dashboard.md @@ -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 + +发现未关联的本地项目。 diff --git a/.blueprint/modules/project-env-scan.md b/.blueprint/modules/project-env-scan.md new file mode 100644 index 0000000..ec721a5 --- /dev/null +++ b/.blueprint/modules/project-env-scan.md @@ -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 + +保存和读取项目环境工具信息。 diff --git a/.blueprint/modules/project-mgmt.md b/.blueprint/modules/project-mgmt.md new file mode 100644 index 0000000..051a110 --- /dev/null +++ b/.blueprint/modules/project-mgmt.md @@ -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 + +扫描指定目录下的子文件夹,辅助批量添加。 diff --git a/.blueprint/modules/project-templates.md b/.blueprint/modules/project-templates.md new file mode 100644 index 0000000..5704005 --- /dev/null +++ b/.blueprint/modules/project-templates.md @@ -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 + +首次启动时自动插入预设模板。 diff --git a/.blueprint/modules/project-tools.md b/.blueprint/modules/project-tools.md new file mode 100644 index 0000000..9ab40d2 --- /dev/null +++ b/.blueprint/modules/project-tools.md @@ -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 + +一键启动关联工具,支持自定义启动命令。 diff --git a/.blueprint/modules/repo-registry.md b/.blueprint/modules/repo-registry.md new file mode 100644 index 0000000..1126a4d --- /dev/null +++ b/.blueprint/modules/repo-registry.md @@ -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 + +查看、搜索、删除已注册仓库。 diff --git a/.blueprint/modules/settings-page.md b/.blueprint/modules/settings-page.md new file mode 100644 index 0000000..da39779 --- /dev/null +++ b/.blueprint/modules/settings-page.md @@ -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 + +显示当前版本号、检查更新、下载安装、错误信息可复制。 diff --git a/.blueprint/modules/shell-launch.md b/.blueprint/modules/shell-launch.md new file mode 100644 index 0000000..8658240 --- /dev/null +++ b/.blueprint/modules/shell-launch.md @@ -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 等。 diff --git a/.blueprint/modules/sqlite-db.md b/.blueprint/modules/sqlite-db.md new file mode 100644 index 0000000..0ee15ad --- /dev/null +++ b/.blueprint/modules/sqlite-db.md @@ -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 + +表结构变更时的自动迁移逻辑。 diff --git a/.blueprint/modules/tags-groups.md b/.blueprint/modules/tags-groups.md new file mode 100644 index 0000000..ad0a541 --- /dev/null +++ b/.blueprint/modules/tags-groups.md @@ -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 + +分组的增删改、排序、成员管理。 diff --git a/.blueprint/modules/v2rayn-import.md b/.blueprint/modules/v2rayn-import.md new file mode 100644 index 0000000..876e5c8 --- /dev/null +++ b/.blueprint/modules/v2rayn-import.md @@ -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 + +扫描全部节点列表。 diff --git a/.blueprint/modules/workspace-mgmt.md b/.blueprint/modules/workspace-mgmt.md new file mode 100644 index 0000000..2e7511f --- /dev/null +++ b/.blueprint/modules/workspace-mgmt.md @@ -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)。 diff --git a/.blueprint/modules/wsl-proxy.md b/.blueprint/modules/wsl-proxy.md new file mode 100644 index 0000000..0d532a8 --- /dev/null +++ b/.blueprint/modules/wsl-proxy.md @@ -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,验证代理是否生效且一致。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c5e2c9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# Dev Manager Tauri + +Git 多空间开发管理器,Tauri v2 + React + TypeScript + Rust。 + +## 项目蓝图 + +本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 + +**每次对话都应遵循以下流程:** + +### 1. 了解当前状态 +对话开始时,先读取 `.blueprint/manifest.yaml` 了解项目模块和状态。 + +### 2. 讨论需求时更新蓝图 +- 确认了新需求 → 在 manifest.yaml 新增模块,创建 `.blueprint/modules/.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/CD:GitHub 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/ 部署和更新指南 +``` diff --git a/docs/tauri-cicd-guide.md b/docs/tauri-cicd-guide.md index ee50ff2..3ba58a7 100644 --- a/docs/tauri-cicd-guide.md +++ b/docs/tauri-cicd-guide.md @@ -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)** --- diff --git a/docs/tauri-updater-guide.md b/docs/tauri-updater-guide.md new file mode 100644 index 0000000..e9febc9 --- /dev/null +++ b/docs/tauri-updater-guide.md @@ -0,0 +1,408 @@ +# Tauri v2 客户端自动更新指南 + +> **方案版本**: v1.0(2026-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://./latest.json" + ], + "dialog": false // false = 用代码控制更新 UI;true = 系统原生弹窗 + } + } +} +``` + +### 关键说明 + +| 字段 | 作用 | 注意事项 | +|------|------|---------| +| `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://./releases/v0.1.11/_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 } + | { 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({ 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 ( +
+

当前版本 v{appVersion}

+ + {state.status === "latest" && 已是最新版本} + + {state.status === "available" && ( + <> + v{state.version} 可用 + + + )} + + {state.status === "downloading" && ( + 下载中 {state.progress}% + )} + + {state.status === "error" && ( +
+

{state.message}

+ +
+ )} + + {state.status !== "available" && ( + + )} +
+ ); +} +``` + +### 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 + push(PowerShell 不支持 &&,分开执行) +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-cn-hangzhou.aliyuncs.com/latest.json", + "https://github.com///releases/latest/download/latest.json" +] +``` + +--- + +## 九、调试技巧 + +### 9.1 验证 latest.json 可访问 + +```powershell +curl https:///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() +``` diff --git a/package.json b/package.json index 244f976..10c2ad2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d925720..a696de8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d0ed6d9..c5b4f53 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 575999f..01a22bb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,4 +38,5 @@ ureq = { version = "2", features = ["json"] } open = "5" base64 = "0.22" urlencoding = "2" +serde_yaml = "0.9" diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs new file mode 100644 index 0000000..a26df1f --- /dev/null +++ b/src-tauri/src/commands/blueprint.rs @@ -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, + pub updated: Option, + #[serde(default)] + pub areas: Vec, + #[serde(default)] + pub modules: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BlueprintArea { + pub id: String, + pub name: String, + pub color: Option, +} + +#[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, + // 运行时填充:从 modules/.md 解析 + #[serde(skip_deserializing, default)] + pub description: Option, + #[serde(skip_deserializing, default)] + pub decisions: Vec, + #[serde(skip_deserializing, default)] + pub tasks: Vec, +} + +#[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, + pub files: Option, + pub depends: Option, + pub acceptance: Option, + pub notes: Option, +} + +// ── 完整蓝图返回结构 ───────────────────────────────────────────────────────── + +#[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, 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, + decisions: Vec, + tasks: Vec, +} + +fn parse_module_md(content: &str) -> ParsedModule { + let mut tasks = Vec::new(); + let mut description = None; + let mut decisions: Vec = 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 = 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 +} diff --git a/src-tauri/src/commands/canvas.rs b/src-tauri/src/commands/canvas.rs new file mode 100644 index 0000000..2bf8001 --- /dev/null +++ b/src-tauri/src/commands/canvas.rs @@ -0,0 +1,30 @@ +use crate::db::pool; +use rusqlite::params; + +/// 读取产品组的画布状态(JSON 字符串),不存在时返回 null +#[tauri::command] +pub fn get_canvas_state(group_id: String) -> Result, 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(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9dae5f8..881cf47 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,3 +16,5 @@ pub mod publish; pub mod templates; pub mod tags; pub mod cicd; +pub mod blueprint; +pub mod canvas; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a3fb3ac..f81871b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c07074e..4f86891 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx new file mode 100644 index 0000000..1334e26 --- /dev/null +++ b/src/components/dashboard/BlueprintModal.tsx @@ -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 = { + 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 = { + done: "✅", + in_progress: "🔵", + todo: "📋", + concept: "💭", +}; + +// ── 领域分组节点 ───────────────────────────────────────────────────────────── + +function AreaNode({ data }: { data: { label: string; color: string } }) { + return ( +
+
+
+ + {data.label} + +
+
+ ); +} + +// ── 模块节点 ───────────────────────────────────────────────────────────────── + +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 ( +
+ + + + + +
+ + {data.name} +
+ + {/* 进度条 */} +
+
+
+ +
+ {cfg.label} + {tasksTotal > 0 && ( + {tasksDone}/{tasksTotal} + )} +
+
+ ); +} + +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(null); + + const { data: blueprint, isLoading } = useQuery({ + 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 ( +
+
+ {/* 标题栏 */} +
+
+

项目蓝图

+

+ {projectName} + {blueprint?.manifest.updated && ( + 更新于 {blueprint.manifest.updated} + )} +

+
+ +
+ + {/* 统计条 */} + {stats && ( +
+ + + + +
+ + 任务 {stats.tasks_done}/{stats.total_tasks} + + {stats.dispatchable > 0 && ( + + )} + {/* 总进度条 */} +
+
+
0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%` }} + /> +
+ + {stats.total_modules > 0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}% + +
+
+ )} + + {/* 图例 */} + {blueprint && ( +
+ 图例: +
+ + 依赖 +
+
+ + 关联 +
+
+ {blueprint.manifest.areas.map((a) => ( +
+ + {a.name} +
+ ))} +
+ )} + + {/* 主体区域 */} +
+ {/* React Flow 画布 */} +
+ {isLoading ? ( +
+ 加载中... +
+ ) : !blueprint ? ( +
+
🗺️
+

该项目尚未创建蓝图

+

+ 在项目根目录创建 .blueprint/ 目录即可启用 +

+
+ ) : ( + + + + + )} +
+ + {/* 侧边面板 */} + {sidePanel && ( +
+ {sidePanel.type === "module" ? ( + setSidePanel(null)} + /> + ) : ( + setSidePanel({ type: "module", module: mod })} + onClose={() => setSidePanel(null)} + /> + )} +
+ )} +
+
+
+ ); +} + +// ── 统计小标签 ─────────────────────────────────────────────────────────────── + +function StatBadge({ color, label, count }: { color: string; label: string; count: number }) { + return ( +
+ + {label} + {count} +
+ ); +} + +// ── 模块详情面板 ───────────────────────────────────────────────────────────── + +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 = { + 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 = { + done: "已完成", + in_progress: "进行中", + todo: "待开发", + concept: "构思中", + }; + + return ( +
+ {/* 标题 */} +
+
+
+ +

{mod.name}

+
+
+ {cfg.label} · {mod.progress}% + {area && ( + + {area.name} + + )} +
+
+ +
+ + {/* 进度条 */} +
+
+
+ + {/* 描述 */} + {mod.description && ( +

+ {mod.description} +

+ )} + + {/* 决策记录 */} + {mod.decisions && mod.decisions.length > 0 && ( +
+

决策记录

+
    + {mod.decisions.map((d, i) => ( +
  • + · + {d} +
  • + ))} +
+
+ )} + + {/* 任务卡列表 */} + {mod.tasks.length > 0 && ( +
+ {(["in_progress", "todo", "concept", "done"] as const).map((status) => { + const tasks = grouped[status]; + if (!tasks || tasks.length === 0) return null; + return ( +
+

+ {groupLabels[status]} ({tasks.length}) +

+
+ {tasks.map((t, i) => ( + + ))} +
+
+ ); + })} +
+ )} +
+ ); +} + +// ── 任务卡 ─────────────────────────────────────────────────────────────────── + +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 ( +
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" + }`} + > +
+ {prefix} + {task.title} + {task.complexity && ( + + {task.complexity} + + )} + {isDispatchable && ( + + )} +
+ + {expanded && ( +
+ {task.files && ( +

文件: {task.files}

+ )} + {task.acceptance && ( +

验收: {task.acceptance}

+ )} + {task.depends && ( +

依赖: {task.depends}

+ )} + {task.notes && ( +

备注: {task.notes}

+ )} +
+ )} +
+ ); +} + +// ── 下一步汇总面板 ────────────────────────────────────────────────────────── + +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 ( +
+
+

下一步

+ +
+ + {actionItems.length === 0 ? ( +

暂无可执行任务

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

进行中

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

可派发

+
+ {actionItems + .filter((i) => i.dispatchable) + .map((item, i) => ( +
+ + +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 2a0aa93..ce05202 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -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(null); + const [groupView, setGroupView] = useState("default"); const winMembers = members.filter(({ project: p }) => p.platform !== "wsl"); const wslMembers = members.filter(({ project: p }) => p.platform === "wsl"); @@ -231,53 +234,86 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP {members.length} 个项目 -
- - {/* 列头 */} -
-
本地项目
-
部署信息
-
- - {/* 主体:每个项目一行,左=卡片 右=部署信息,平台分组作行标题 */} -
- {/* Windows 分组 */} -
-

Windows

- {winMembers.length} 个 + {/* 视图切换 */} +
+ +
- {winMembers.length > 0 - ? winMembers.map(({ project, role }) => ( - - )) - :

暂无

- } - - {/* WSL 分组 */} -
-

WSL / Linux

- {wslMembers.length} 个 -
- {wslMembers.length > 0 - ? wslMembers.map(({ project, role }) => ( - - )) - :

暂无

- }
+ {/* ── 资源视角画布 ── */} + {groupView === "resource" && ( + + )} + + {/* ── 默认视图 ── */} + {groupView === "default" && ( + <> + {/* 列头 */} +
+
本地项目
+
部署信息
+
+ + {/* 主体:每个项目一行,左=卡片 右=部署信息,平台分组作行标题 */} +
+ {/* Windows 分组 */} +
+

Windows

+ {winMembers.length} 个 +
+ {winMembers.length > 0 + ? winMembers.map(({ project, role }) => ( + + )) + :

暂无

+ } + + {/* WSL 分组 */} +
+

WSL / Linux

+ {wslMembers.length} 个 +
+ {wslMembers.length > 0 + ? wslMembers.map(({ project, role }) => ( + + )) + :

暂无

+ } +
+ + )} + {deployTarget && ( ; + colorClass: string; +} + +interface CustomItem { + id: string; + name: string; + color: string; + variant?: "node" | "container"; // 默认 "node" +} + +// ── 节点删除按钮 ───────────────────────────────────────────────── + +function DeleteBtn({ nodeId }: { nodeId: string }) { + const { deleteElements } = useReactFlow(); + return ( + + ); +} + +// ── 数据节点 ───────────────────────────────────────────────────── + +function ProjectNode({ id, data }: NodeProps) { + const { project: p, role } = data as ProjectNodeData; + return ( +
+ + + +
+

{p.name}

+
+ + {p.platform === "wsl" ? "WSL" : "Windows"} + + {role && {role}} + {p.status && {p.status}} +
+ {p.tech_stack &&

{p.tech_stack}

} +
+
+ ); +} + +function GitNode({ id, data }: NodeProps) { + const { label, url, projectName } = data as GitNodeData; + return ( +
openUrl(url).catch(() => {})}> + + + +
+

🔀 {label}

+

{projectName}

+

{url.replace(/^https?:\/\/(www\.)?/, "").replace(/\.git$/, "")}

+
+
+ ); +} + +function EnvNode({ id, data }: NodeProps) { + const { variant, url, projectName } = data as EnvNodeData; + const isProd = variant === "prod"; + return ( +
openUrl(url).catch(() => {})}> + + + +
+

{isProd ? "🌐 生产" : "🧪 测试"}

+

{projectName}

+

{url.replace(/^https?:\/\//, "")}

+
+
+ ); +} + +function ServerNode({ id, data }: NodeProps) { + const { note, projectName } = data as ServerNodeData; + return ( +
+ + + +
+

🖥 Server

+

{projectName}

+

{note}

+
+
+ ); +} + +function FocusNode({ id, data }: NodeProps) { + const { text, projectName } = data as FocusNodeData; + return ( +
+ + + +
+

📌 当前焦点

+

{projectName}

+

{text}

+
+
+ ); +} + +// ── 自定义节点(画布上)────────────────────────────────────────── + +function CustomNode({ id, data }: NodeProps) { + const { name, color } = data as CustomNodeData; + const { updateNodeData, addNodes, addEdges, getNode } = useReactFlow(); + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(name); + const [editColor, setEditColor] = useState(color || DEFAULT_CUSTOM_COLOR); + + const c = color || DEFAULT_CUSTOM_COLOR; + + const startEdit = () => { + setEditName(name); + setEditColor(color || DEFAULT_CUSTOM_COLOR); + setEditing(true); + }; + + const commit = () => { + const n = editName.trim(); + if (!n) return; + updateNodeData(id, { name: n, color: editColor }); + setEditing(false); + }; + + const cancel = () => setEditing(false); + + // 添加子节点:在当前节点正下方创建一个新自定义节点并连线 + const addChildNode = (e: React.MouseEvent) => { + e.stopPropagation(); + const parent = getNode(id); + if (!parent) return; + const childId = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`; + addNodes([{ + id: childId, + type: "custom", + position: { x: parent.position.x, y: parent.position.y + 130 }, + data: { name: "子节点", color: c }, + }]); + addEdges([{ + id: `e-${id}-${childId}`, + source: id, + target: childId, + sourceHandle: "bottom", + targetHandle: "top", + type: "smoothstep", + style: { stroke: c, strokeWidth: 1.5 }, + markerEnd: { type: MarkerType.ArrowClosed, color: c, width: 12, height: 12 }, + }]); + }; + + const handleStyle = { + width: 8, height: 8, + background: c, + border: "2px solid white", + boxShadow: "0 0 0 1px " + c, + }; + + if (editing) { + return ( +
+ {/* 编辑态保留隐形 handle 以免断线 */} + + + + +
+ setEditName(e.target.value)} + onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") commit(); if (e.key === "Escape") cancel(); }} + onPointerDown={(e) => e.stopPropagation()} + className="text-[11px] border border-gray-200 rounded px-1.5 py-0.5 outline-none focus:ring-1 bg-white w-full" + /> +
+ {PRESET_COLORS.map((pc) => ( +
+
+ + +
+
+
+ ); + } + + return ( +
+ {/* 顶部:接受父节点连线(target) */} + + {/* 底部:发起子节点连线(source) */} + + {/* 左右保留隐形 handle 供通用连线使用 */} + + + + {/* 删除 & 编辑按钮 */} + + + +
+ +

{name}

+
+ + {/* 底部:添加子节点快捷按钮 */} + +
+ ); +} + +// ── 容器节点(大图,子节点在内部)──────────────────────────────── + +function ContainerNode({ id, data, selected }: NodeProps) { + const { name, color } = data as CustomNodeData; + const { updateNodeData, addNodes, getNodes, deleteElements } = useReactFlow(); + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(name); + const c = color || DEFAULT_CUSTOM_COLOR; + + const commitEdit = () => { + const n = editName.trim(); + if (!n) { setEditing(false); return; } + updateNodeData(id, { name: n }); + setEditing(false); + }; + + const addChild = (e: React.MouseEvent) => { + e.stopPropagation(); + const siblings = getNodes().filter((n) => n.parentId === id); + const col = Math.floor(siblings.length / 3); + const row = siblings.length % 3; + addNodes([{ + id: `cchild-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`, + type: "containerChild", + position: { x: 16 + col * 152, y: 52 + row * 52 }, + parentId: id, + extent: "parent" as const, + data: { name: "子节点", color: c }, + }]); + }; + + return ( +
+ + + + + {/* 标题栏 */} +
+ + {editing ? ( + setEditName(e.target.value)} + onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") commitEdit(); if (e.key === "Escape") setEditing(false); }} + onBlur={commitEdit} + onPointerDown={(e) => e.stopPropagation()} + className="flex-1 min-w-0 text-[12px] font-bold bg-transparent border-b outline-none" + style={{ color: c, borderColor: c }} + /> + ) : ( + { setEditName(name); setEditing(true); }} + > + {name} + + )} + + {/* 颜色色板(hover 显示) */} + {!editing && ( +
+ {PRESET_COLORS.map((pc) => ( +
+ )} + + {/* 操作按钮 */} + + +
+
+ ); +} + +// ── 容器子节点(在容器内部)────────────────────────────────────── + +function ContainerChildNode({ id, data }: NodeProps) { + const { name, color } = data as ContainerChildNodeData; + const { updateNodeData, deleteElements } = useReactFlow(); + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(name); + const c = color || DEFAULT_CUSTOM_COLOR; + + const commit = () => { + const n = editName.trim(); + if (n) updateNodeData(id, { name: n }); + setEditing(false); + }; + + return ( +
+ + + + {editing ? ( + setEditName(e.target.value)} + onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter" || e.key === "Escape") commit(); }} + onBlur={commit} + onPointerDown={(e) => e.stopPropagation()} + className="flex-1 min-w-0 text-[11px] font-medium bg-transparent border-b outline-none" + style={{ color: c, borderColor: c }} + /> + ) : ( + { setEditName(name); setEditing(true); }} + title={name} + > + {name} + + )} + +
+ ); +} + +// 必须定义在模块顶层 +const nodeTypes = { + project: ProjectNode, + git: GitNode, + env: EnvNode, + server: ServerNode, + focus: FocusNode, + custom: CustomNode, + container: ContainerNode, + containerChild: ContainerChildNode, +}; + +// ── 持久化类型 ──────────────────────────────────────────────────── + +interface PersistedCanvas { + nodes: { + id: string; type?: string; position: XYPosition; data: Record; + style?: React.CSSProperties; + parentId?: string; + extent?: "parent"; + }[]; + edges: { id: string; source: string; target: string; style?: React.CSSProperties; type?: string }[]; + customItems: CustomItem[]; +} + +// ── 生成资料列表 ────────────────────────────────────────────────── + +function buildSidebarItems(members: GroupMember[]): Record { + const projects: SidebarItem[] = []; + const gitItems: SidebarItem[] = []; + const envItems: SidebarItem[] = []; + const serverItems: SidebarItem[] = []; + const focusItems: SidebarItem[] = []; + + members.forEach(({ project: p, role }) => { + projects.push({ id: `s-proj-${p.id}`, nodeType: "project", label: p.name, sublabel: role ?? p.type ?? undefined, data: { project: p, role }, colorClass: "border-blue-200" }); + if (p.repo_url) gitItems.push({ id: `s-git-${p.id}-o`, nodeType: "git", label: "origin", sublabel: p.name, data: { label: "origin", url: p.repo_url, projectName: p.name }, colorClass: "border-slate-200" }); + if (p.upstream_url) gitItems.push({ id: `s-git-${p.id}-u`, nodeType: "git", label: "upstream", sublabel: p.name, data: { label: "upstream", url: p.upstream_url, projectName: p.name }, colorClass: "border-slate-200" }); + if (p.prod_url) envItems.push({ id: `s-env-${p.id}-p`, nodeType: "env", label: "生产", sublabel: p.name, data: { variant: "prod", url: p.prod_url, projectName: p.name }, colorClass: "border-emerald-200" }); + if (p.staging_url) envItems.push({ id: `s-env-${p.id}-s`, nodeType: "env", label: "测试", sublabel: p.name, data: { variant: "staging", url: p.staging_url, projectName: p.name }, colorClass: "border-amber-200" }); + if (p.server_note) serverItems.push({ id: `s-srv-${p.id}`, nodeType: "server", label: "Server", sublabel: p.name, data: { note: p.server_note, projectName: p.name }, colorClass: "border-slate-200" }); + if (p.recent_focus) focusItems.push({ id: `s-foc-${p.id}`, nodeType: "focus", label: "当前焦点", sublabel: p.name, data: { text: p.recent_focus, projectName: p.name }, colorClass: "border-indigo-200" }); + }); + + const result: Record = {}; + if (projects.length) result["🗂 项目"] = projects; + if (gitItems.length) result["🔀 Git 仓库"] = gitItems; + if (envItems.length) result["🌐 环境"] = envItems; + if (serverItems.length) result["🖥 服务器"] = serverItems; + if (focusItems.length) result["📌 焦点"] = focusItems; + return result; +} + +// ── 拖拽核心逻辑(pointer events,供两种卡片复用)──────────────── + +interface DragHandlers { + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerCancel: (e: React.PointerEvent) => void; +} + +function useDragToCanvas( + ghostLabel: string, + canvasRef: React.RefObject, + screenToFlowPosition: (p: XYPosition) => XYPosition, + onDrop: (nodeType: string, data: Record, position: XYPosition) => void, + nodeType: string, + data: Record, +): DragHandlers { + const ghostRef = useRef(null); + const activeRef = useRef(false); + + const cleanup = (el: HTMLDivElement | null) => { + if (el && document.body.contains(el)) document.body.removeChild(el); + }; + + const onPointerDown = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + activeRef.current = true; + const ghost = document.createElement("div"); + ghost.style.cssText = [ + "position:fixed","pointer-events:none","z-index:9999", + "background:white","border:1.5px solid #a78bfa","border-radius:8px", + "padding:4px 10px","font-size:11px","color:#1f2937", + "box-shadow:0 4px 16px rgba(0,0,0,0.15)","white-space:nowrap", + `left:${e.clientX + 12}px`,`top:${e.clientY + 12}px`, + ].join(";"); + ghost.textContent = ghostLabel; + document.body.appendChild(ghost); + ghostRef.current = ghost; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!activeRef.current || !ghostRef.current) return; + ghostRef.current.style.left = `${e.clientX + 12}px`; + ghostRef.current.style.top = `${e.clientY + 12}px`; + }; + + const onPointerUp = (e: React.PointerEvent) => { + if (!activeRef.current) return; + e.currentTarget.releasePointerCapture(e.pointerId); + activeRef.current = false; + cleanup(ghostRef.current); + ghostRef.current = null; + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + onDrop(nodeType, data, screenToFlowPosition({ x: e.clientX, y: e.clientY })); + } + }; + + const onPointerCancel = (e: React.PointerEvent) => { + activeRef.current = false; + cleanup(ghostRef.current); + ghostRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }; + + return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }; +} + +// ── 资料列表卡片 ────────────────────────────────────────────────── + +interface DataItemCardProps { + item: SidebarItem; + canvasRef: React.RefObject; + onDrop: (nodeType: string, data: Record, position: XYPosition) => void; + screenToFlowPosition: (p: XYPosition) => XYPosition; +} + +function DataItemCard({ item, canvasRef, onDrop, screenToFlowPosition }: DataItemCardProps) { + const handlers = useDragToCanvas( + item.sublabel ? `${item.label} ${item.sublabel}` : item.label, + canvasRef, screenToFlowPosition, onDrop, item.nodeType, item.data, + ); + return ( +
+ {item.label} + {item.sublabel && {item.sublabel}} +
+ ); +} + +// ── 自定义节点列表卡片(支持编辑名称和颜色)──────────────────────── + +interface CustomItemCardProps { + item: CustomItem; + canvasRef: React.RefObject; + onDrop: (nodeType: string, data: Record, position: XYPosition) => void; + screenToFlowPosition: (p: XYPosition) => XYPosition; + onRemove: (id: string) => void; + onUpdate: (id: string, name: string, color: string) => void; +} + +function CustomItemCard({ item, canvasRef, onDrop, screenToFlowPosition, onRemove, onUpdate }: CustomItemCardProps) { + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(item.name); + const [editColor, setEditColor] = useState(item.color); + + const c = item.color || DEFAULT_CUSTOM_COLOR; + + const isContainer = item.variant === "container"; + const nodeType = isContainer ? "container" : "custom"; + const handlers = useDragToCanvas( + `${isContainer ? "▣" : "✦"} ${item.name}`, + canvasRef, screenToFlowPosition, onDrop, + nodeType, { name: item.name, color: item.color, customItemId: item.id }, + ); + + const commit = () => { + const name = editName.trim(); + if (!name) return; + onUpdate(item.id, name, editColor); + setEditing(false); + }; + + const cancel = () => { + setEditName(item.name); + setEditColor(item.color); + setEditing(false); + }; + + if (editing) { + return ( +
+ {/* 名称输入 */} + setEditName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") cancel(); }} + className="text-[11px] border border-gray-200 rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-violet-300 bg-white w-full" + /> + {/* 颜色色板 */} +
+ {PRESET_COLORS.map((pc) => ( +
+ {/* 操作按钮 */} +
+ + +
+
+ ); + } + + return ( +
+ {/* 可拖拽主体 */} +
+ {isContainer ? "▣" : "✦"} + {item.name} +
+ {/* 编辑按钮 */} + + {/* 删除按钮 */} + +
+ ); +} + +// ── Sidebar ─────────────────────────────────────────────────────── + +interface SidebarProps { + groups: Record; + customItems: CustomItem[]; + canvasRef: React.RefObject; + onDrop: (nodeType: string, data: Record, position: XYPosition) => void; + screenToFlowPosition: (p: XYPosition) => XYPosition; + onAddCustom: (name: string, variant?: "node" | "container") => void; + onRemoveCustom: (id: string) => void; + onUpdateCustom: (id: string, name: string, color: string) => void; +} + +function ResourceSidebar({ groups, customItems, canvasRef, onDrop, screenToFlowPosition, onAddCustom, onRemoveCustom, onUpdateCustom }: SidebarProps) { + const [input, setInput] = useState(""); + + const commit = (variant?: "node" | "container") => { + const name = input.trim(); + if (!name) return; + onAddCustom(name, variant); + setInput(""); + }; + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter") commit("node"); + }; + + return ( +
+ + {/* ── 资料列表 ── */} +
+

资料列表

+ {Object.entries(groups).map(([category, items]) => ( +
+

{category}

+ {items.map((item) => ( + + ))} +
+ ))} + {Object.keys(groups).length === 0 && ( +

暂无项目资料

+ )} +
+ + {/* ── 分割线 ── */} +
+
+ 自定义 +
+
+ + {/* ── 自定义节点 ── */} +
+

自定义节点

+ + {/* 添加输入框 */} +
+
+ setInput(e.target.value)} + onKeyDown={onKey} + placeholder="名称…" + className="flex-1 min-w-0 border border-violet-200 rounded-md px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-violet-300 bg-white placeholder-gray-300" + /> + +
+ +
+ + {/* 自定义节点列表 */} + {customItems.map((item) => ( + + ))} + + {customItems.length === 0 && ( +

输入名称后回车添加

+ )} +
+
+ ); +} + +// ── 树形布局算法(仅针对自定义节点) ───────────────────────────────── + +function computeTreeLayout(nodes: Node[], edges: Edge[]): Map { + const customIds = new Set(nodes.filter((n) => n.type === "custom").map((n) => n.id)); + if (customIds.size === 0) return new Map(); + + const childrenOf = new Map(); + const parentsOf = new Map(); + + for (const e of edges) { + if (customIds.has(e.source) && customIds.has(e.target)) { + childrenOf.set(e.source, [...(childrenOf.get(e.source) ?? []), e.target]); + parentsOf.set(e.target, [...(parentsOf.get(e.target) ?? []), e.source]); + } + } + + // 根节点 = 没有自定义父节点的自定义节点 + const roots = [...customIds].filter((id) => !parentsOf.has(id)); + if (roots.length === 0) return new Map(); // 存在环,跳过 + + // BFS 分配层级 + const level = new Map(); + const queue: string[] = [...roots]; + roots.forEach((r) => level.set(r, 0)); + while (queue.length > 0) { + const cur = queue.shift()!; + for (const child of childrenOf.get(cur) ?? []) { + if (!level.has(child)) { + level.set(child, level.get(cur)! + 1); + queue.push(child); + } + } + } + + // 按层分组,居中排列 + const byLevel = new Map(); + for (const [id, lv] of level) { + byLevel.set(lv, [...(byLevel.get(lv) ?? []), id]); + } + + const H_GAP = 200, V_GAP = 130; + const positions = new Map(); + for (const [lv, ids] of byLevel) { + const totalW = (ids.length - 1) * H_GAP; + ids.forEach((id, i) => positions.set(id, { x: i * H_GAP - totalW / 2, y: lv * V_GAP })); + } + return positions; +} + +// ── CanvasInner ─────────────────────────────────────────────────── + +function CanvasInner({ groups, groupId }: { groups: Record; groupId: string }) { + const [initialized, setInitialized] = useState(false); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [customItems, setCustomItems] = useState([]); + const { screenToFlowPosition, getNode } = useReactFlow(); + const canvasRef = useRef(null); + const saveTimerRef = useRef | null>(null); + + // 初始加载(从数据库) + useEffect(() => { + getCanvasState(groupId).then((raw) => { + if (raw) { + try { + const parsed = JSON.parse(raw) as PersistedCanvas; + setNodes((parsed.nodes ?? []) as Node[]); + setEdges((parsed.edges ?? []) as Edge[]); + setCustomItems(parsed.customItems ?? []); + } catch { /* 数据损坏时忽略 */ } + } + setInitialized(true); + }).catch(() => setInitialized(true)); + }, [groupId]); + + // 防抖保存(加载完成后才监听变化) + useEffect(() => { + if (!initialized) return; + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + const state: PersistedCanvas = { + nodes: nodes.map(({ id, type, position, data, style, parentId, extent }) => ({ + id, type, position, data: data as Record, + ...(style ? { style } : {}), + ...(parentId ? { parentId } : {}), + ...(extent === "parent" ? { extent: "parent" as const } : {}), + })), + edges: edges.map(({ id, source, target, style, type }) => ({ id, source, target, style, type })), + customItems, + }; + saveCanvasState(groupId, JSON.stringify(state)).catch(() => {}); + }, 800); + return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; + }, [nodes, edges, customItems, groupId, initialized]); + + // 自定义节点之间的连线用彩色箭头,其余用灰色 + const onConnect: OnConnect = useCallback( + (conn) => { + 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", + style: { stroke: edgeColor, strokeWidth: 1.5 }, + markerEnd: bothCustom + ? { type: MarkerType.ArrowClosed, color: edgeColor, width: 12, height: 12 } + : undefined, + }, eds)); + }, + [setEdges, getNode], + ); + + // 一键整理:将所有自定义节点按父子层级重新排布 + const applyTreeLayout = useCallback(() => { + const positions = computeTreeLayout(nodes, edges); + if (positions.size === 0) return; + setNodes((nds) => nds.map((n) => { + const pos = positions.get(n.id); + return pos ? { ...n, position: pos } : n; + })); + }, [nodes, edges, setNodes]); + + const handleDrop = useCallback( + (nodeType: string, data: Record, position: XYPosition) => { + const isContainer = nodeType === "container"; + setNodes((nds) => nds.concat({ + id: `${nodeType}-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, + type: nodeType, + position, + data, + // 容器节点需要初始尺寸,子节点才能正确定位 + ...(isContainer ? { style: { width: 300, height: 220 } } : {}), + })); + }, + [setNodes], + ); + + const addCustomItem = useCallback((name: string, variant?: "node" | "container") => { + setCustomItems((prev) => [...prev, { id: `ci-${Date.now()}`, name, color: DEFAULT_CUSTOM_COLOR, variant: variant ?? "node" }]); + }, []); + + const removeCustomItem = useCallback((id: string) => { + setCustomItems((prev) => prev.filter((c) => c.id !== id)); + }, []); + + // 更新名称/颜色,同步更新画布上已放置的同源节点 + const updateCustomItem = useCallback((id: string, name: string, color: string) => { + setCustomItems((prev) => prev.map((c) => c.id === id ? { ...c, name, color } : c)); + setNodes((nds) => nds.map((n) => + n.type === "custom" && (n.data as CustomNodeData).customItemId === id + ? { ...n, data: { ...n.data, name, color } } + : n + )); + }, [setCustomItems, setNodes]); + + return ( +
+ +
+ + + + {nodes.length === 0 && ( +
+
+

🗂

+

从左侧拖入资源,自由排列

+
+
+ )} +
+ {nodes.length > 0 && ( +
+ {nodes.some((n) => n.type === "custom") && ( + + )} + +
+ )} +
+
+ ); +} + +// ── 导出 ───────────────────────────────────────────────────────── + +interface Props { + members: GroupMember[]; + groupId: string; +} + +export function GroupResourceCanvas({ members, groupId }: Props) { + const groups = useMemo(() => buildSidebarItems(members), [members]); + return ( +
+ + + +
+ ); +} diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx index 09dfbcb..90b3290 100644 --- a/src/components/dashboard/ProjectCard.tsx +++ b/src/components/dashboard/ProjectCard.tsx @@ -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) {
{(p.updated_at ?? "").slice(0, 10)}
+