Compare commits

..

1 Commits

Author SHA1 Message Date
lanrtop
412ab8491f feat(feature-pruning): 撤销零数据/死代码功能(留撤盘点批次 1)
All checks were successful
Push & PR Check / check (push) Successful in 1m23s
Push & PR Check / check (pull_request) Successful in 58s
按「管家准入与退出」撤销实测无使用的功能,台账见 docs/tasks/2026-10-04-feature-pruning.md:
- 项目级工具(project_tools 0 行):删除项目级 command;保留全局快启,
  project_tools.rs/ProjectToolsModal 改名 global_tools.rs/GlobalToolsModal
- 部署命令(deploy_commands 0 行):删除 4 个 command、DeployModal 与「部署配置」按钮;
  删除未编译的 deploy_info.rs 与空壳 DeployInfoPanel
- 宿主机应用(0 行)、项目个人备忘(1 条,表与数据保留)、活动日志与 relations(0 行)
- GitHub 残留:git_accounts 建表与 github_client_* 设置种子;publish.rs 改名 local_git.rs
- 空壳 NetworkPage

数据原则:不 DROP 表、不删已有行,只移除建表语句、两段 FK 迁移与读写代码。
核查后撤回 server_network_config:镜像选择功能在用(ServerSoftwareBlock 调用)。

Rules-Applied: R01,R05
Feature-Confirmed: true

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:34:32 +09:00
40 changed files with 229 additions and 2224 deletions

View File

@ -1,5 +1,5 @@
# 意图目录
> catalog_version: 2.0 | 前言 → path:AGENTS.md#项目定位 | 语法 → path:docs/ai-context/intent-catalog-guide.md
> catalog_version: 1.0 | 前言 → path:AGENTS.md#项目定位 | 语法 → path:docs/ai-context/intent-catalog-guide.md
## 书签
- 当前 → path:docs/tasks/2026-10-03-coordination.md#0. 方针与本阶段终点
@ -29,14 +29,6 @@
- 不做: 不替 agent 思考或调度;不接管子项目自有事实;不因已经做了而保留
- 墙: W11,W14
## INT-04 MVP 收口
- rev: 2.0
- 意图: agent 拿到子项目即能开工、能自验、能交付,摩擦能闭环回传;四条都可用后再谈扩展
- 人看: 在 enterprise-system 真实开发中,agent 能开工、自验、交付,摩擦能回传;常驻文档与代码一致
- 机器: module:mvp-closure;path:docs/tasks/2026-10-03-coordination.md#0. 方针与本阶段终点
- 不做: 不开新能力;冻结的扩展项不派单;不改子项目业务规则与凭据
- 墙: W2,W3,W14
## 附录
- 承重墙 → path:.blueprint/modules/intent-catalog.md#承重墙
- 决策与否决 → 各模块「决策记录」;终验否决理由 → 卡上 `- 终验:` 行

View File

@ -56,7 +56,8 @@ modules:
- id: project-tools
name: 项目工具链
area: frontend
status: done
status: abandoned
note: "2026-10-04 feature-pruning B1 撤销项目级工具(project_tools 0 行);全局快启保留为 global_tools"
progress: 100
position: [500, 150]
@ -258,9 +259,7 @@ modules:
name: 蓝图 Buff
area: backend
status: in_progress
progress: 90
updated: 2026-10-04
note: "8 张卡全 ✅;B2 终验 2026-10-04 被否决(只改共用文档的提交仍误标无关 📋 卡,实例 IC10),剩余缺口为方案 C(files 重叠改记 DB 信号),MVP 收口期冻结为扩展。"
progress: 86
position: [2250, 0]
- id: claude-autonomy-config
@ -273,7 +272,8 @@ modules:
- id: user-project-todos
name: 项目个人备忘
area: frontend
status: done
status: abandoned
note: "2026-10-04 feature-pruning B1 撤销(1 条,表保留不删)"
progress: 100
position: [2000, 150]
@ -331,14 +331,16 @@ modules:
- id: deploy-info
name: 项目部署信息
area: frontend
status: done
status: abandoned
note: "2026-10-04 feature-pruning B1 撤销部署命令(deploy_commands 0 行)与死文件;生产/测试地址展示保留"
progress: 100
position: [3000, 150]
- id: host-apps
name: 宿主机应用管理
area: frontend
status: done
status: abandoned
note: "2026-10-04 feature-pruning B1 撤销(server_host_apps 0 行)"
progress: 100
position: [2750, 600]
@ -536,13 +538,13 @@ modules:
note: "两层模型(人持有的意图目录 .blueprint/catalog.md + 蓝图正文),书装意图、指向事实;卡侧 intent 钉版,agent 编译可判定 /goal,确认卡兼作最小任务简报,回执写进提交。IC1 设计已确认(最小文件优先方案);IC2–IC4 只新增文档、可与 P8 并行,门控与接线等 P8 收口后做,IC13 按实测数据决定去留。"
position: [4000, 950]
- id: mvp-closure
name: MVP 收口
- id: feature-pruning
name: 功能留撤盘点
area: infra
status: in_progress
progress: 75
progress: 40
updated: 2026-10-04
note: "2026-10-04 P8 达成后的阶段终点(INT-04):炼境侧关账、清理、修常驻文档漂移(M2–M4,编进 /goal),四条能力在 enterprise-system 真实开发中只读取证(M5);扩展项冻结见统筹文档 §3。"
note: "按管家准入与退出阶段性过滤冗余功能;台账 docs/tasks/2026-10-04-feature-pruning.md。B1 撤零数据/死代码 agent_verify 通过待人终验(PR),B2 产品组退役待设计。"
position: [4250, 950]
edges:

View File

@ -13,7 +13,6 @@ id: blueprint-buff
- Buff 同步后顺手调用 `get_blueprint()`,内部触发 `append_usage_snapshot()`,使飞轮数据与实际开发进度对齐
- 使用文件路径匹配而非 commit message 解析,与 AI 工具/提交信息风格完全解耦
- **files 重叠只作兜底(2026-10-02,B1)**:本批提交已有卡被显式推进(新增 🔵/✅/🔴 标题行)则不推断,整批无推进才视为「agent 忘了加锁」。原因:两阶段锁下在做的卡落地时已非 📋,files 重叠只能碰到未开工卡,精度结构性偏低。曾评估「提交说明引用卡 ID」——正文自由文本 4 次命中 3 次误判(「阶段二 N3/N4/N5 待启动」),结构化 trailer 又与上条解耦决策冲突且兜不住忘加锁场景,故不采用;「可能相关」改记 DB 信号不改卡片(方案 C)留作残余噪声持续时的升级路径
- **剩余缺口与冻结(2026-10-04,MVP 收口 M2)**:manifest 曾记 86%,剩余 14% 起初判为未更新的进度数字;同日 B2 终验被否决(见 B2 卡),证明剩余是真实缺口——兜底推断的误标须按上条升级路径做方案 C(files 重叠改记 DB「可能相关」信号、不改卡片,L 级)。用户定:误标属噪声、不阻挡 MVP 四条能力,方案 C 冻结为扩展,模块保持 in_progress。
## 任务卡
@ -80,5 +79,3 @@ id: blueprint-buff
- 实现:infer_task_marks 增加第二个显式归属条件 edits_existing_lines——本批 .blueprint/modules patch 在 hunk 内有 `-` 行(删改既有内容)即视为 agent 在跟进维护,不推断;纯追加(新立 📋 卡、新模块)仍走兜底,保留「立卡即实现、忘了加锁」的捕获。
- verification: "2026-10-03:cargo test 284 passed / 0 failed / 5 ignored;buff 10 passed(新增 edits_existing_lines 单测与端到端 ⑧⑨);变异验证见 acceptance。回放 master 260 个 first-parent 批次:B1 下无推进 172 批,B2 新增抑制 27 批,其中含代码改动的 6 批:19b9388(本次误标)、8f3be6b、0d4c2ad、cbc1d46、23de433、0552180,其余为只改蓝图的 docs/chore 提交(本就不会命中代码卡);纯追加立卡的 9881b56、9c2064e、4f5cf43 保持兜底。"
- known_limits: "同批若只改写了无关 📋 卡的文字同时改代码且忘了加锁,B2 会少标(漏信号,不误标)。下次再出现误标时按 B1 决策记录升级到方案 C(files 重叠改记 DB「可能相关」信号,不改卡片,L 级)。"
- human_verify: "2026-10-04 否决:主仓库 12:45 快进到 ef09ef2(只改 docs/tasks/2026-10-03-coordination.md,无蓝图改动)后约 1 分钟,buff 把 intent-catalog 未开工的 📋 IC10(files 含该文件)标为 🔵——无显式推进、files 共用即兜底命中,属 B1 known_limits「不更新蓝图的提交碰共享文件仍可能误标」。B1/B2 两道闸已生效但不足以消除此类误标。"
- 终验: 2026-10-04 否决:只改共用文档、不动蓝图的提交仍会把无关 📋 卡误标 🔵(实例 IC10)

View File

@ -0,0 +1,28 @@
---
id: feature-pruning
name: 功能留撤盘点
status: in_progress
progress: 40
---
## 概述
项目管理改用多维表 + 子主题之前,先按「管家准入与退出」阶段性过滤冗余功能。
台账(证据、价值评估、批次状态)单一来源:`docs/tasks/2026-10-04-feature-pruning.md`,本文件只登记任务卡。
## 任务卡
### 🔵 B1 批次 1:撤销零数据 / 死代码功能
- complexity: M
- status: in_progress
- files: src-tauri/src/commands/{host_apps,activity,user_todos,deploy_info,spaces,servers,projects,templates,mod}.rs, src-tauri/src/commands/project_tools.rs → global_tools.rs, src-tauri/src/commands/publish.rs → local_git.rs, src-tauri/src/models.rs, src-tauri/src/{db,lib}.rs, src/components/dashboard/{ProjectToolsModal → GlobalToolsModal,DeployModal,HostAppsPanel,UserTodoPanel,DeployInfoPanel,Dashboard,ProjectCard,ServerManagerPanel}.tsx, src/components/network/NetworkPage.tsx, src/lib/commands.ts
- acceptance:
- agent_verify: [cargo test] 无 FAILED;[pnpm typecheck] exit 0;[grep] 全仓无 get_project_tools / get_deploy_commands / get_host_apps / get_user_todos / add_activity 残留(get_server_network_config 核查后撤回:在用,移出本卡);[旧库副本] 迁移启动不报错
- human_verify: 项目卡与服务器面板不再出现工具链、部署、宿主机应用、备忘入口,其余功能正常
- 数据原则:不 DROP 表、不删已有行,只移除建表与读写代码
- 进展(2026-10-04):agent_verify 前三项通过;旧库副本迁移未实测;server_network_config 核查后撤回(在用);待 PR 与人终验
### 📋 B2 批次 2:产品组退役 + 旧 projects 表(L,先设计)
- complexity: L
- status: todo
- 前置:接入判定改为项目 token;组→标签迁移(仅 ≥2 成员组);`list_group_projects` MCP 契约撤销(T1)

View File

@ -1,56 +0,0 @@
# MVP 收口(mvp-closure)
> status: in_progress | created: 2026-10-04 | intent: INT-04 | 统筹:docs/tasks/2026-10-03-coordination.md §0
> 来源:2026-10-04 用户在 P8 达成后定下一阶段为「炼境 MVP 收口」,并定以 enterprise-system 真实开发作为验证场。
## 概述
把炼境收口到 MVP:agent 拿到子项目能开工、能自验、能交付,摩擦能闭环回传。炼境侧只做收拾(关账、清理、修文档漂移),
不开新能力;四条能力是否真的可用,在 enterprise-system 的真实开发中只读观察取证。
## 决策记录
- 2026-10-04 用户定:扩展项(部署控制面、P4b–e、P5/P6、治理缺口②③、DSH 接管、Codex 模型切换、多维视图、架构演进、
intent-catalog Phase 4+、概念占位显式化、project-dev-variables V14–V16)冻结,MVP 达成后再排;清单在统筹文档 §3。
- 2026-10-04 用户定:以 enterprise-system 真实开发验证四条能力(M5),原「开发变量人终验」(V13)并入 M5;
intent-catalog 的 enterprise 试点继续冻结,先把四条跑稳。
- 观察边界:enterprise 主工作区有在制品,炼境侧只经 MCP 只读查询取证;要改 enterprise 就开临时 worktree、走 PR。
## 任务卡
### ✅ M2 — blueprint-buff 关账
- status: done
- complexity: S
- intent: INT-04@2.0
- files: .blueprint/modules/blueprint-buff.md, .blueprint/manifest.yaml
- acceptance: "agent_verify: [shell] grep -cE '^### (📋|🔵|💭|🔴)' .blueprint/modules/blueprint-buff.md → 0 且 grep -c '^### ✅' 同文件 → 8;git -C 主仓库 log 中 2026-10-04 的 ff 合并后 git status --porcelain -- .blueprint/modules 为空(零误标)并写入 B2 卡 human_verify 证据;manifest blueprint-buff 为 status: done、progress: 100,模块决策记录写明「剩余 14% 为未更新的进度数字,核心卡已全部完成」。"
- 验收变更(2026-10-04,按自验报告纪律报备,用户在会话中选定):执行中发现 buff 12:46 把主仓库 IC10 误标 🔵,「零误标、14% 只是数字」的前提被证伪。改为:B2 卡记终验否决与证据、决策记录写明剩余缺口为方案 C 并冻结、manifest 保持 in_progress 并改 progress 90;原 acceptance 中 status: done / progress: 100 与「主仓库 .blueprint/modules 空」两项不再适用。
- verification: "2026-10-04(INT-04 goal):blueprint-buff 8 张卡全 ✅(未完成标题计数 0);B2 human_verify 记 2026-10-04 否决与 IC10 实例;决策记录新增「剩余缺口与冻结」;manifest blueprint-buff progress 86 → 90、status in_progress。"
- 为什么:manifest 记 86%,但模块 8 张卡全 ✅;唯一缺 B2 终验记录,而 2026-10-04 主仓库 6 次快进合并后 .blueprint/modules 零误标,正是 B2 的终验现象。
### ✅ M3 — 本轮临时项目与 worktree 清理
- status: done
- complexity: S
- intent: INT-04@2.0
- files: docs/tasks/2026-10-03-coordination.md
- acceptance: "agent_verify: [shell] git worktree list 不含 tmp-p7skill-review、tmp-delivery-status-review、tmp-p10-verify-mut、dev-manager-tauri-wt-p8、dev-manager-tauri-wt-intent、dev-manager-tauri-wt-p8g;[MCP] list_all_projects 不含路径带 lj-p8 的登记。human_verify: 用户手动删除统筹文档 §5「待你手动删除」所列目录与文件。"
- verification: "2026-10-04(INT-04 goal):git worktree list 按六个已清理名称计数 → 0;全局 MCP list_all_projects 完整输出(29 个项目)中 lj-p8 → 0。human_verify(手动删除 §5 所列目录与文件)待用户。顺带发现:list_all_projects 中 dev-manager-tauri 出现两条(组 f2418a3b 与 ffa9e42d),超出本卡范围,未处理。"
### ✅ M4 — 常驻文档漂移修复
- status: done
- complexity: S
- intent: INT-04@2.0
- files: docs/ai-context/project-brief.md, AGENTS.md, docs/ai-context/agent-button-system.md, docs/templates/agents-meta-rules.md, docs/tasks/2026-09-30-dev-variables-materialize.md
- acceptance: "agent_verify: [shell] ① grep -c 'onboard/' docs/ai-context/project-brief.md → ≥1;② grep -lE '^#+ 炼境自己的 Agent 按钮清单' AGENTS.md docs/ai-context/agent-button-system.md → 恰好 1 个文件,另一处改为指针;③ grep -c '1.10.0' docs/templates/agents-meta-rules.md → ≥1,且其规则正文与 src-tauri/resources/onboarding/AGENTS.md.tmpl 的 ONBOARDING_MANAGED 块逐节对照无缺漏(差异写进卡片);④ grep -c '未合入 master' docs/tasks/2026-09-30-dev-variables-materialize.md → 0;⑤ grep -c '\\.agent/task\\.md' docs/ai-context/agent-button-system.md → 0 或改为与现行做法一致的说明;pnpm typecheck exit 0。"
- 说明:只改文档,不动 resources/onboarding/ 模板(模板改动会触发指纹测试与版本升级,超出本卡)。
- verification: "2026-10-04(INT-04 goal):① project-brief 架构速查补 mcp_inject.rs 与 onboard/ 两行;② 按钮清单以 AGENTS.md 块外区为单一来源(并入 agent-button-system 版多出的 pnpm test 与一键接入两行),agent-button-system 原节改为「按钮清单的位置」指针;③ agents-meta-rules 头部改 version 1.10.0 + aligned_with,能力包五条同步为模板原文、T1/T3/T4/自查措辞同步;④ dev-variables 简报头部改为 frozen、模块已在 master;⑤ agent-button-system 持久层去掉不存在的最小接入文件,改为与单档接入一致。"
- 差异清单(agents-meta-rules ↔ AGENTS.md.tmpl ONBOARDING_MANAGED 1.10.0,全文见 meta-rules「与分发模板的对照」节): "托管块 7 节中,核心开发约束、需求对齐协议、Git 工作流三节 meta-rules 无、以模板为准不复制;能力包一节已同步,Node/pnpm 工具链只留指针;agent 按钮与验证路径——meta-rules 缺载体就近选择表、完成的定义、侦察→驻军、agent_verify/human_verify 分层与三分定位启发式,多出分工原则、按钮清单模板、降级约定(已不在托管块内);多 AI CLI 协作与交接模板内容等价。托管块外四支柱、T1–T4、铁律、自查已同步。"
### 📋 M5 — enterprise-system 实战验证四条能力
- status: todo
- complexity: M
- intent: INT-04@2.0
- depends: M2, M3, M4
- files: .blueprint/modules/mvp-closure.md
- acceptance: "agent_verify: [MCP 只读,project_id 为 enterprise 的 canonical id] ① 能开工:verify_onboarding clients 三端 converged 且 probe_client_parity all_applicable_ready=true;② 能自验:炼境日志或会话证据中至少 1 次 read_project_test_account 成功;③ 能交付:get_delivery_status 至少 1 个 PR 的 CI passed 并已合并;④ 摩擦闭环:list_envoy_returns 中 enterprise 来源的回传均有处置回执。human_verify: 用户在 enterprise 开发中确认——新会话无需手工配置即可开工;需要登录时 agent 先查炼境不索要账号(原 V13);认可该次交付;回传内容是真问题。"
- 说明:随用户开发进度积累证据,不进 M2–M4 的 /goal;证据攒够后统筹会话一次性核对并出终验清单。

View File

@ -321,10 +321,8 @@ docs/templates/ 炼境接入包模板草案(设计层)
| 能力 | 调用方式 | 通过标准 |
|------|---------|---------|
| TypeScript 类型检查 | `pnpm typecheck` | 无输出,exit 0 |
| 前端单元测试 | `pnpm test` | exit 0,无 FAILED |
| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED |
| MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 |
| 一键接入子项目 | MCP `plan_onboarding(project_id)` → 计划给用户看、用户确认 → `onboard_project(project_id, expected_digest=<计划 digest>, confirmed=true)`(两次选项相同;UI 投影为同名 Tauri command,同一本体) | 计划 `blocked` 为空;报告 `status=done`(`partial` 按 `steps`、`completion`、`next_actions` 修复后重跑即收敛;再跑一次全部 `converged`) |
| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 |
| 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 |

View File

@ -88,8 +88,9 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
### 持久层 — 目标持久化
让 agent 跨会话续接,不靠对话记忆:
- 炼境与已接入子项目:蓝图(`.blueprint/`),规范随接入包分发
- 接入包只有一档(见下文「分发给子项目的方案」),不设「最小接入」,也不另设轻量任务文件
- 炼境项目:蓝图(`.blueprint/`)
- 子项目完整接入:随炼境分发的蓝图规范
- 子项目最小接入:`.agent/task.md`
蓝图在体系里的三个角色:
1. **目标的结构化载体** — 把自然语言目标翻译成带 acceptance 的可执行任务卡
@ -178,9 +179,17 @@ Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机
### 原则三:acceptance 是体系的核心关节
体系其他部分都能降级,acceptance 不能将就。acceptance 写得可执行,自验才是真自验;写得模糊,整个环路是假闭合,无人值守名存实亡。
## 按钮清单的位置
## 炼境自己的 Agent 按钮清单
单一来源在 AGENTS.md「炼境自己的 Agent 按钮清单」节(常驻上下文,跨工具读取),此处不复制。
| 能力 | 调用方式 | 通过标准 |
|------|---------|---------|
| TypeScript 类型检查 | `pnpm typecheck` | 无输出,exit 0 |
| 前端单元测试 | `pnpm test` | exit 0,无 FAILED |
| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED |
| MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 |
| 一键接入子项目 | MCP `plan_onboarding(project_id)` → 计划给用户看、用户确认 → `onboard_project(project_id, expected_digest=<计划 digest>, confirmed=true)`(两次选项相同;UI 投影为同名 Tauri command,同一本体) | 计划 `blocked` 为空;报告 `status=done`(`partial` 按 `steps`、`completion`、`next_actions` 修复后重跑即收敛;再跑一次全部 `converged`) |
| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 |
| 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 |
## 分发给子项目的方案

View File

@ -30,8 +30,6 @@ src-tauri/src/credential_store.rs Windows 本机凭证引用、校验与兼容
src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口)
src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表)
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、稳定 helper 入口)
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划、编排与报告、L1 三端检查;不依赖 Tauri、连接注入)
src-tauri/src/commands/dev_variables.rs 项目开发变量本体(Agent 登记、人填写、受认证单项读取)
src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据)
src-tauri/resources/onboarding/ 接入包分发模板

View File

@ -1,8 +1,8 @@
# 测试账户落地到被忽略文件(project-dev-variables 增量)
> status: frozen(2026-10-04 MVP 收口期冻结为扩展,见统筹文档 §3)| complexity: L(拆为 3 张 M 卡)| created: 2026-09-30 | blueprint: project-dev-variables(模块已在 master;对应卡 V14–V16 为 📋)
> status: todo | complexity: L(拆为 3 张 M 卡)| created: 2026-09-30 | blueprint: project-dev-variables(该模块尚在 `feat/project-dev-variables` 分支,V7 blocked,未合入 master)
> ⚠️ 处置条款:本简报 done 后 24h 内删除;结论并入 `project-dev-variables` 模块的决策与任务卡,不让简报成为第二事实来源。
> 类型:Feature。解冻后开工前需用户确认本设计(卡已在模块内立为 V14–V16)。
> 类型:Feature。开工前需用户确认本设计;且须等 `project-dev-variables` 合入 master 后在其模块内立卡。
## 为什么(压缩背景 + 证据)

View File

@ -71,11 +71,11 @@
| 5 | P8f register_project 控制面投影 | S | 已提前并行(只依赖 P8-0) | [MCP] 链路实测待 P8e 合并后做 |
| ~~6~~ | ~~P8g UI 一键接入~~ ✅ | M | P8e 后 | human_verify 交用户 |
| ~~并行~~ | ~~删除项目登记时一并解绑 Gitea 仓库~~ ✅ G8 | S/M | master | 来自重复登记排查会话的推荐任务卡(轮询会继续往已删除的项目 ID 写数据),主要改 projects.rs / gitea.rs,可并行 |
| MVP-1 | project-dev-variables 人终验 → 并入 mvp-closure M5 | — | — | 用户 2026-10-04 定:以 enterprise-system 真实开发验证四条能力(开工/自验/交付/摩擦闭环),原 V13 终验并入;炼境侧只经 MCP 只读取证 |
| MVP-2 | blueprint-buff 关账 → mvp-closure M2 ✅ | S | master | 8 张卡全 ✅,但 B2 终验被否决(IC10 误标),剩余为方案 C、冻结;模块保持 in_progress 90% |
| MVP-3 | 本轮清理 → mvp-closure M3 | — | — | 2026-10-04 agent 侧已完成(取消登记 2 个、删 worktree 5 个),剩 §5 的手动删除 |
| MVP-4 | 常驻文档漂移修复 → mvp-closure M4 | S | master | 纯文档可直推;碰 agents-meta-rules 时同步检查接入包模板版本 |
| 扩展(冻结) | 部署控制面(project-capability-control-plane)、P4b–P4e、P5、P6、治理缺口 ②③(governance-feedback-loop)、DSH 接管(dsh-takeover-readiness)、Codex 模型切换(codex-model-switch)、多维视图(manage-multi-view)、架构演进(architecture-evolution)、intent-catalog Phase 4+(IC6–IC13:门控脚本、接线、书签交接、enterprise 试点——用户 2026-10-04 定继续冻结)、project-dev-variables V14–V16(测试账户落地到被忽略文件)、blueprint-buff 方案 C(files 重叠改记 DB 信号,B2 终验 2026-10-04 被否决后的升级路径) | | | 2026-10-04 用户定:MVP 收口期间不派单,MVP 达成后再排 |
| MVP-1 | project-dev-variables 人终验 | — | master | human_verify 交用户;模块进度已 100%、待终验关账 |
| MVP-2 | blueprint-buff 剩余 14%:先核实是否核心 | S | master | 核心则做完,非核心则冻结并在模块里写明理由 |
| MVP-3 | 本轮临时项目与 worktree 清理 | — | — | 2026-10-04 agent 侧已完成(取消登记 2 个、删 worktree 5 个),剩 §5 的手动删除 |
| MVP-4 | 常驻文档漂移修复(§0 必做 4 的五处) | S | master | 纯文档可直推;碰 agents-meta-rules 时同步检查接入包模板版本 |
| 扩展(冻结) | 部署控制面(project-capability-control-plane)、P4b–P4e、P5、P6、治理缺口 ②③(governance-feedback-loop)、DSH 接管(dsh-takeover-readiness)、Codex 模型切换(codex-model-switch)、多维视图(manage-multi-view)、架构演进(architecture-evolution)、intent-catalog Phase 4+(IC6–IC13:门控脚本、接线、书签交接、enterprise 试点) | | | 2026-10-04 用户定:MVP 收口期间不派单,MVP 达成后再排 |
| 扩展(冻结) | 概念占位登记显式化(UI 不选目录的占位登记) | S | — | 用户 2026-10-04 定:MVP 保持现状;出现具体干扰(诊断、统计、接入入口把占位当普通项目)再出 S 级确认卡 |
| 后续小修 | P8f 审查转入:L4 路径归一函数统一到 crate::path_key(含 Codex 信任路径、WSL 发行版与 UNC 换算)、I1 update_project 有效根变化才查重并排除自身、I2 project_root_with_conn 拼接 UNC 形式 wsl_path 的已有缺陷 | S | master | 碰 mcp_inject.rs,P8 主线后再做 |
| 后续小修 | backfill_notes_archive 静默跳过非 UTF-8 旧笔记(dev-manager-tauri 有 4 条);PR #34 审查遗留 L3(保留他项目 Codex 块不留痕)、L4(沉默统计并列偏向非规范 ID,补测试)、I1(注释说明两处语义差异)、I2(沉默列表带 id) | S | master | 不碰 P8 热点文件的可并行 |

View File

@ -0,0 +1,95 @@
# 炼境功能留撤盘点(持续维护)
> 起因(2026-10-04 用户):项目管理改用 enterprise-system 风格多维表,务实功能收进项目详情「子主题」,冗余功能去掉。
> 本文是**阶段性过滤的单一台账**:每批处理完更新「状态」列与「处理记录」,不另开文档。
> 数据证据是 2026-10-04 快照(本机库只读计数),易变——复核时重新计数,不信本表旧数。
## 0. 评估口径
- **A|agent 价值**:管家主线程度 / 是否有 MCP 投影。
- **H|人用价值**:以本机库真实行数为准(只说明「有无数据」,不说明使用频率;只读查看型功能可能被低估)。
- **C|维护成本**:代码体量与耦合面。
- 判据对齐 AGENTS.md「管家准入与退出」:实测无收益、被替代、或维护成本超过收益 → 撤。
状态取值:`待处理` / `进行中` / `已完成` / `待用户判断` / `转后续`。
## 1. 核心保留(管家主线)
| 模块 | A | H | C | 证据 |
|---|---|---|---|---|
| mcp-server / mcp-single-server | 高 | — | 高 | 所有 agent 能力的入口 |
| mcp-config-inject | 高 | — | 高 | 三端注入本体(mcp_inject.rs 约 4300 行) |
| onboarding-pack | 高 | 中 | 高 | 一键接入主线 |
| agent-client-capability-packs | 高 | — | 中 | 能力包 + 使者回传(envoy_dispositions 63 条) |
| toolchain-capability-pack | 高 | — | 中 | get_toolchain_status / prepare_toolchain |
| agent-access-reliability | 高 | 中 | 中 | 设置页 readiness |
| project-resource-broker | 高 | 中 | 高 | 凭证代办与交付状态 |
| gitea-integration | 高 | 中 | 高 | pr_events 362 条 |
| cicd-workflow | 高 | 中 | 中 | 4 个 MCP 工具 |
| blueprint-reader / view / governance | 高 | 高 | 高 | 任务上下文主载体 |
| blueprint-flywheel / flywheel-intelligence | 高 | 中 | 中 | commit_metrics 1840、commit_files 19105 |
| blueprint-feedback / project-mentor | 高 | 中 | 中 | project_notes 199、project_snapshots 2036 |
| runtime-diagnostics / health-center | 高 | 中 | 中 | runtime_logs 517 |
| project-mgmt / workspace-mgmt | 中 | 高 | 高 | 28 个项目、2 个空间;多维表数据源 |
| devtools-scan / project-env-scan | 中 | 中 | 中 | 工具链能力包输入(dev_tools 33) |
| git-ops / shell-launch | 低 | 高 | 低 | 开编辑器/终端等高频小功能 |
| sqlite-db / tray-service / auto-update / settings-page | — | 高 | 中 | 基础设施 |
## 2. 合并(能力保留,换落点)
| 模块 | 落点 | 理由 | 状态 |
|---|---|---|---|
| project-dashboard(看板) | 多维表 | 看板与管理两套视图合一;看板「分组/独立」依赖产品组 | 待处理 |
| tags-groups 中的标签 | 多维表可选字段 | 仅 1 个标签 1 条映射,留着成本低 | 待处理 |
| repo-registry | 项目档案 repo_url + Gitea | git_repos 仅 2 条,已被 gitea_repos(7)替代 | 待处理 |
| server-registry + ssh-config-sync + vscode-remote + server-software + service-config | 资源页「服务器」一页 + 项目详情子主题「部署」 | 5 个模块是同一件事(servers 2 / server_software 9 / service_configs 5) | 待处理 |
| cloud-products + cloud-docker-apps + cloud-db-instances | 「云资源」一页 | 同上(7 / 1 / 5) | 待处理 |
| agent-infrastructure | 并入接入包 | 与一键接入重叠,入口藏在蓝图弹窗 | 待处理 |
| blueprint-onboarding(提示词) | 并入接入面板 | 被一键接入取代,MCP 工具保留 | 待处理 |
| api-keys | 并入 credential_store 统一凭证存储 | 11 条真实使用;**需先核实是否明文存储** | 待处理 |
## 3. 撤销
### 3a. 零数据 / 死代码(批次 1)
| 项 | 证据 | 改动范围 | 状态 |
|---|---|---|---|
| project-tools 项目工具链 | project_tools 0 行 | 项目级 command 删除;文件改名 global_tools.rs / GlobalToolsModal(全局快启 global_tools 有 4 条,保留) | 已完成 |
| 部署命令 deploy_commands / deploy-info | deploy_commands 0 行;deploy_info.rs 未编译、DeployInfoPanel 为 `export {}` | spaces.rs 的 4 个部署 command、DeployModal 与「部署配置」按钮、两个死文件(生产/测试地址展示保留) | 已完成 |
| host-apps 宿主机应用 | server_host_apps 0 行 | host_apps.rs、HostAppsPanel、ServerManagerPanel 入口、删服务器时的级联清理 | 已完成 |
| ~~server_network_config~~ | 0 行,但 set_server_mirror 写、安装命令生成读、ServerSoftwareBlock 在调 | **撤回:属在用的镜像选择功能**,归入「合并 → 服务器」一并考虑 | 不处理 |
| activity_log / relations | 均 0 行;add_activity 无调用点,get_activity 恒为空 | activity.rs、ProjectCard「动态」按钮与活动区、get_project 去掉 recent_activities、projects.rs 删除清理 | 已完成 |
| user-project-todos 个人备忘 | 1 条(表与该行保留在库中,界面不再可见) | user_todos.rs、UserTodoPanel、ProjectCard 入口 | 已完成 |
| github-auth / github-publish 残留 | git_accounts 仅 db.rs 建表、无引用;github_client_* 设置种子 | db.rs 建表、索引与种子删除;publish.rs 改名 local_git.rs(内容是本地 git 与 WSL 路径辅助) | 已完成 |
| NetworkPage 孤儿文件 | `export {}` | 删文件 | 已完成 |
批次 1 数据原则:**不 DROP 表、不删已有行**(可逆);只移除建表语句与读写代码,旧库残留表无害。
### 3b. 需先设计(批次 2,L 级)
| 项 | 为什么不能直接删 | 状态 |
|---|---|---|
| 产品组本体 + product-group-mcp | 约 150 处引用;被当作「已接入」标记(MCP 重注入判断、健康统计、一键接入 product_group_buff 步骤);`list_group_projects` 是 MCP 契约(T1)。须先把接入判定改为项目 token、组→标签迁移(仅 ≥2 成员的组) | 转后续 |
| 旧 `projects` 表 | commit_metrics.rs 仍 LEFT JOIN;deploy_commands 外键指向它;credential_store 测试插入它 | 转后续 |
## 4. 待用户判断
| 项 | 证据 | C | 倾向 | 状态 |
|---|---|---|---|---|
| wsl-proxy + v2rayn-import | 受监控工具 1 个、network_mode_log 0 | 约 1050 行 | 撤或移出炼境(与管家定位无关) | 待用户判断 |
| source-library 源码库 | 2 个项目、4 个版本、0 个分类 | 630 行 | 撤 | 待用户判断 |
| project-templates 项目模板 | 15 个模板 / 151 个文件(未区分是否为种子) | 约 2460 行 | 视实际使用 | 待用户判断 |
| GroupResourceCanvas 资源画布 | canvas_states 6 条 | 约 1370 行 | 随产品组撤 | 待用户判断 |
| claude-autonomy-config | 只读展示 beast-mode「狂暴模式」安装状态(初判「绑 Claude 违反跨 agent」不准确,已从撤销移来) | 约 80 行 + 弹窗 | 视是否还看 | 待用户判断 |
## 5. 对多维表方案的影响
- 标签几乎未用(1 条),预置视图不宜以标签分组为主;优先用已有数据的字段:空间、状态、类型、技术栈、接入状态。
- 项目详情抽屉的子主题 tab 草案:概要 / 蓝图与导师 / 交付(CI、Gitea、PR)/ 环境(工具链、开发变量)/ 部署(服务器)。
## 6. 处理记录
| 日期 | 批次 | 内容 | 证据 |
|---|---|---|---|
| 2026-10-04 | — | 建立台账;核查后更正三处初判(claude-autonomy-config 移入待判断;旧 projects 表、产品组移入批次 2) | 本会话盘点 |
| 2026-10-04 | B1 | 3a 七项完成、server_network_config 撤回;分支 feat/feature-pruning。db.rs 移除 relations/activity_log/project_tools/git_accounts/deploy_commands/user_project_todos/server_host_apps 建表及两段 FK 迁移,不 DROP 旧表 | cargo test 408 通过 0 失败;pnpm typecheck exit 0;被撤 command 全仓 grep 零残留。**未做**:旧库副本迁移实测(复制真实库的命令被拒),待人终验 |

View File

@ -1,7 +1,6 @@
---
version: 1.10.0
aligned_with: src-tauri/resources/onboarding/AGENTS.md.tmpl ONBOARDING_MANAGED 1.10.0
updated: 2026-10-04
version: 1.2.0
updated: 2026-09-21
usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标准内容
---
@ -10,34 +9,12 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标
> 本节由炼境接入包分发,规则技术栈无关,适用于所有项目和所有 AI 工具。
> 项目特定的路径映射见 `docs/ai-context/project-context.yaml`。
## 与分发模板的对照(ONBOARDING_MANAGED 1.10.0,2026-10-04 逐节核对)
分发实体是 `src-tauri/resources/onboarding/AGENTS.md.tmpl`,措辞以模板为准;本文件是设计层,补「为什么」,不重复模板全文。
| 模板小节(托管块内) | 本文件 | 差异 |
|---|---|---|
| 核心开发约束(包管理、代码规范、Playwright 版本固定) | 无 | 不复制,以模板为准 |
| 需求对齐协议(Bug/Feature 分流、S 级三行卡、M/L 完整卡、Feature-Confirmed、补录≠确认卡) | 无 | 不复制,以模板为准 |
| Git 工作流(git-workflow.md、Conventional Commits) | 无 | 不复制,以模板为准 |
| 炼境项目上下文与能力包 | 同名小节 | 五条已同步为模板原文;Node/pnpm 工具链小节只留指针 |
| agent 按钮与验证路径(1–6 条 + 收束) | 「Agent 按钮体系」 | 本文件有同源同边界、acceptance 写法、自验报告纪律及其「为什么」;缺载体就近选择表、完成的定义、侦察→驻军、agent_verify/human_verify 分层与三分定位启发式(以模板为准);本文件多出的分工原则、按钮清单模板、自验失败降级约定已不在托管块内(炼境自身见 AGENTS.md 块外区) |
| 多 AI CLI 协作 | 「跨 AI 工具交接模板」 | 内容等价,模板为编号三条 |
| 模板小节(托管块外) | 本文件 | 差异 |
|---|---|---|
| 四支柱、文档防腐 T1–T4、铁律、定期防腐自查 | 同名小节 | 已同步:T1 补「(若存在)」、T3/T4 改指「本文件(AGENTS.md)」、自查路径写全 `docs/ai-context/` |
## 炼境项目上下文与能力包
- `.lian-jing/context.json` 是项目 `project_id` 的工具无关单一事实来源;不要从客户端项目 UI、用户级插件或全局配置推断项目身份。
- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。
- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。
- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。
- 遇到炼境分发的模板、约定或 MCP 工具的摩擦,或验证出跨项目可复用的实践时,调用 `report_envoy_return` 回传炼境(只追加信号,不必逐条征得用户同意;Claude 会话结束时也可跑 `/envoy`);何时回传、字段怎么填见 lian-jing skill「遇到摩擦时回传炼境」一节。
### Node/pnpm 工具链
见模板同名小节(.nvmrc / packageManager 为版本事实源、mise 最小声明、get_toolchain_status 先查、prepare_toolchain 须用户确认);措辞以模板为准。
- 项目根目录 `.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;客户端项目 UI、用户级插件和全局配置都不能替代它。
- `.claude/liangjing.json` 仅用于兼容已接入的旧 Claude 项目;发现 legacy-only 状态时,应通过炼境迁移,不应复制或猜测 ID。
- `.lian-jing/capability-packs.json` 仅描述炼境托管基线的版本与自检。升级必须显式报告版本不符,并且不能覆盖项目自己的业务规则、测试、部署声明或手工 MCP 配置。
- 子项目 agent(Claude、Codex、DSH)遇到炼境分发物的摩擦或验证出跨项目实践时,经同一 MCP 入口 `report_envoy_return` 回传;何时回传与字段规范只写在 lian-jing skill,AGENTS 托管块只留一行指路(skill 按需加载,AGENTS 三端必读)。
---
@ -61,7 +38,7 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标
### T1 外部契约变更(最高优先级)
其他代码、服务、或 agent 依赖的定义——接口、共享类型、数据结构、配置键名、环境变量名。
→ 查 `docs/ai-context/project-context.yaml`(若存在)找到记录该契约的文档,检查并同步更新。
→ 查 `docs/ai-context/project-context.yaml` 找到记录该契约的文档,检查并同步更新。
### T2 架构边界变更
模块划分、目录结构、依赖方向、包边界调整。
@ -69,10 +46,10 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标
### T3 工具链变更
引入或替换库、框架、构建工具。
→ 更新本文件(AGENTS.md)技术栈声明;通知炼境重新扫描并更新 `project-context.yaml`。
→ 更新 AGENTS.md 技术栈声明;通知炼境重新扫描并更新 `project-context.yaml`。
### T4 常驻上下文过期
本文件(AGENTS.md)或 `docs/ai-context/project-brief.md` 描述的现实已经改变。
AGENTS.md 或 `docs/ai-context/project-brief.md` 描述的现实已经改变。
→ 立即更新。这两份文档常驻 agent 上下文,过期危害最大。
### 不触发检查
@ -106,9 +83,9 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标
每隔一阵或大改后,对照以下清单:
- [ ] AGENTS.md 里的技术栈声明还和代码一致吗?
- [ ] `docs/ai-context/project-brief.md` 最近 30 天有没有随架构改动更新?
- [ ] `project-brief.md` 最近 30 天有没有随架构改动更新?
- [ ] 有没有两份文档说同一件事且打架?
- [ ] `docs/ai-context/project-context.yaml` 的契约路径还准确吗?
- [ ] `project-context.yaml` 的契约路径还准确吗?
---

View File

@ -1,46 +0,0 @@
use crate::db::pool;
use crate::models::ActivityLog;
use rusqlite::params;
#[tauri::command]
pub fn get_activity(project_id: String, limit: Option<i64>) -> Result<Vec<ActivityLog>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let lim = limit.unwrap_or(20);
let mut stmt = conn
.prepare(
"SELECT * FROM activity_log WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2",
)
.map_err(|e| e.to_string())?;
let result = stmt
.query_map(params![project_id, lim], ActivityLog::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string());
result
}
#[tauri::command]
pub fn add_activity(project_id: String, summary: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM project_workspaces WHERE id = ?1",
params![project_id],
|_| Ok(true),
)
.unwrap_or(false);
if !exists {
return Err(format!("Project not found: {project_id}"));
}
conn.execute(
"INSERT INTO activity_log (project_id, summary, created_at) VALUES (?1, ?2, datetime('now'))",
params![project_id, summary],
)
.map_err(|e| e.to_string())?;
conn.execute(
"UPDATE project_workspaces SET updated_at = datetime('now') WHERE id = ?1",
params![project_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -1323,15 +1323,6 @@ pub(crate) fn sync_blueprint_rules_with(
root: &Path,
sink: &dyn crate::WriteSink,
) -> Result<SyncResult, crate::WriteFailure> {
if crate::mcp_inject::files_managed_by_next(root) {
return Ok(SyncResult {
success: true,
conventions_written: false,
claude_md_action: "skipped".into(),
agents_md_action: "skipped".into(),
message: crate::mcp_inject::FILES_MANAGED_BY_NEXT_SKIP.into(),
});
}
let bp_dir = root.join(".blueprint");
let project_name = root
.file_name()
@ -2885,22 +2876,6 @@ pub fn archive_project_docs(project_path: String) -> Result<ArchiveResult, Strin
#[cfg(test)]
mod tests {
use super::*;
// 共存期防回写:项目文件已由新炼境接管时,蓝图规则同步零写入
#[test]
fn sync_skips_projects_managed_by_next() {
let base = std::env::temp_dir().join(format!("lianjing_sync_guard_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
std::fs::create_dir_all(base.join(".blueprint")).unwrap();
std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"lian-jing-next"}"#).unwrap();
let result = sync_blueprint_rules_with(&base, &crate::DiskSink).unwrap();
assert_eq!((result.agents_md_action.as_str(), result.claude_md_action.as_str()), ("skipped", "skipped"));
assert!(!result.conventions_written);
assert!(!base.join(".blueprint/CONVENTIONS.md").exists());
assert!(!base.join("AGENTS.md").exists() && !base.join("CLAUDE.md").exists());
let _ = std::fs::remove_dir_all(&base);
}
use rusqlite::Connection;
use std::fs;

View File

@ -125,10 +125,6 @@ fn poll_all_buffs(app: &AppHandle) {
fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
let root = Path::new(project_path);
// 共存期防回写:项目文件已由新炼境接管,不再改写其任务卡
if crate::mcp_inject::files_managed_by_next(root) {
return Ok(());
}
// 获取当前 HEAD commit
let current_commit = match get_head_commit(root) {

View File

@ -1,194 +0,0 @@
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DeployInfo {
pub id: String,
pub project_id: String,
pub server_host: Option<String>,
pub server_port: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub ssh_key_path: Option<String>,
pub quick_links: Option<String>, // JSON: [{label, url}]
pub notes: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveDeployInfoInput {
pub server_host: Option<String>,
pub server_port: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub ssh_key_path: Option<String>,
pub quick_links: Option<String>,
pub notes: Option<String>,
}
// ── 业务函数 ──────────────────────────────────────────────────────────────────
pub fn get_info(conn: &Connection, project_id: &str) -> Option<DeployInfo> {
conn.query_row(
"SELECT id, project_id, server_host, server_port, username, password,
ssh_key_path, quick_links, notes, updated_at
FROM project_deploy_info WHERE project_id = ?1",
params![project_id],
|row| {
Ok(DeployInfo {
id: row.get(0)?,
project_id: row.get(1)?,
server_host: row.get(2)?,
server_port: row.get(3)?,
username: row.get(4)?,
password: row.get(5)?,
ssh_key_path: row.get(6)?,
quick_links: row.get(7)?,
notes: row.get(8)?,
updated_at: row.get(9)?,
})
},
)
.ok()
}
pub fn save_info(
conn: &Connection,
project_id: &str,
input: &SaveDeployInfoInput,
) -> Result<DeployInfo, String> {
let existing = get_info(conn, project_id);
let id = existing
.as_ref()
.map(|e| e.id.clone())
.unwrap_or_else(|| format!("deploy-{}", uuid::Uuid::new_v4()));
conn.execute(
"INSERT INTO project_deploy_info
(id, project_id, server_host, server_port, username, password,
ssh_key_path, quick_links, notes, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
ON CONFLICT(project_id) DO UPDATE SET
server_host = ?3,
server_port = ?4,
username = ?5,
password = ?6,
ssh_key_path = ?7,
quick_links = ?8,
notes = ?9,
updated_at = datetime('now')",
params![
id,
project_id,
input.server_host,
input.server_port,
input.username,
input.password,
input.ssh_key_path,
input.quick_links,
input.notes,
],
)
.map_err(|e| e.to_string())?;
get_info(conn, project_id).ok_or_else(|| "保存后查询失败".to_string())
}
// ── Tauri command 薄包装层 ────────────────────────────────────────────────────
#[tauri::command]
pub fn get_deploy_info(project_id: String) -> Result<Option<DeployInfo>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
Ok(get_info(&conn, &project_id))
}
#[tauri::command]
pub fn save_deploy_info(
project_id: String,
input: SaveDeployInfoInput,
) -> Result<DeployInfo, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
save_info(&conn, &project_id, &input)
}
// ── 单元测试 ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE project_deploy_info (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE,
server_host TEXT,
server_port TEXT DEFAULT '22',
username TEXT,
password TEXT,
ssh_key_path TEXT,
quick_links TEXT,
notes TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);",
)
.unwrap();
conn
}
#[test]
fn test_get_empty() {
let conn = setup();
assert!(get_info(&conn, "proj-1").is_none());
}
#[test]
fn test_save_and_get() {
let conn = setup();
let input = SaveDeployInfoInput {
server_host: Some("192.168.1.100".to_string()),
server_port: Some("22".to_string()),
username: Some("root".to_string()),
password: Some("secret".to_string()),
ssh_key_path: None,
quick_links: Some(r#"[{"label":"控制台","url":"https://example.com"}]"#.to_string()),
notes: Some("测试服务器".to_string()),
};
let info = save_info(&conn, "proj-1", &input).unwrap();
assert_eq!(info.server_host.as_deref(), Some("192.168.1.100"));
assert_eq!(info.username.as_deref(), Some("root"));
let fetched = get_info(&conn, "proj-1").unwrap();
assert_eq!(fetched.password.as_deref(), Some("secret"));
}
#[test]
fn test_upsert_overwrites() {
let conn = setup();
let input1 = SaveDeployInfoInput {
server_host: Some("1.1.1.1".to_string()),
server_port: Some("22".to_string()),
username: Some("admin".to_string()),
password: None,
ssh_key_path: None,
quick_links: None,
notes: None,
};
save_info(&conn, "proj-1", &input1).unwrap();
let input2 = SaveDeployInfoInput {
server_host: Some("2.2.2.2".to_string()),
server_port: Some("2222".to_string()),
username: Some("deploy".to_string()),
password: Some("new-pass".to_string()),
ssh_key_path: None,
quick_links: None,
notes: None,
};
let info = save_info(&conn, "proj-1", &input2).unwrap();
assert_eq!(info.server_host.as_deref(), Some("2.2.2.2"));
assert_eq!(info.username.as_deref(), Some("deploy"));
}
}

View File

@ -6,9 +6,8 @@ use std::process::Command as StdCommand;
// ── 数据结构 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct ProjectTool {
pub struct GlobalTool {
pub id: String,
pub project_id: String,
pub name: String,
pub icon: String,
pub exe_path: String,
@ -17,7 +16,7 @@ pub struct ProjectTool {
}
#[derive(Debug, Deserialize)]
pub struct ProjectToolInput {
pub struct GlobalToolInput {
pub name: String,
pub icon: String,
pub exe_path: String,
@ -25,94 +24,10 @@ pub struct ProjectToolInput {
pub sort_order: Option<i32>,
}
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
/// 获取某个项目的所有工具
#[tauri::command]
pub fn get_project_tools(project_id: String) -> Result<Vec<ProjectTool>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, project_id, name, icon, exe_path, args, sort_order
FROM project_tools
WHERE project_id = ?1
ORDER BY sort_order ASC, rowid ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![project_id], |row| {
Ok(ProjectTool {
id: row.get(0)?,
project_id: row.get(1)?,
name: row.get(2)?,
icon: row.get(3)?,
exe_path: row.get(4)?,
args: row.get(5)?,
sort_order: row.get(6)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string());
rows
}
/// 添加工具到项目
#[tauri::command]
pub fn add_project_tool(project_id: String, input: ProjectToolInput) -> Result<String, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO project_tools (id, project_id, name, icon, exe_path, args, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
project_id,
input.name,
input.icon,
input.exe_path,
input.args,
input.sort_order.unwrap_or(0),
],
)
.map_err(|e| e.to_string())?;
Ok(id)
}
/// 更新工具信息
#[tauri::command]
pub fn update_project_tool(id: String, input: ProjectToolInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE project_tools
SET name = ?1, icon = ?2, exe_path = ?3, args = ?4, sort_order = ?5
WHERE id = ?6",
params![
input.name,
input.icon,
input.exe_path,
input.args,
input.sort_order.unwrap_or(0),
id,
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 删除项目工具
#[tauri::command]
pub fn remove_project_tool(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM project_tools WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
// ── 全局快启命令(写入 global_tools 表,无项目 FK 约束)────────────────────────
// ── 全局快启命令(写入 global_tools 表)────────────────────────────────────────
#[tauri::command]
pub fn get_global_tools() -> Result<Vec<ProjectTool>, String> {
pub fn get_global_tools() -> Result<Vec<GlobalTool>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
@ -123,9 +38,8 @@ pub fn get_global_tools() -> Result<Vec<ProjectTool>, String> {
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(ProjectTool {
Ok(GlobalTool {
id: row.get(0)?,
project_id: "__global__".to_string(),
name: row.get(1)?,
icon: row.get(2)?,
exe_path: row.get(3)?,
@ -140,7 +54,7 @@ pub fn get_global_tools() -> Result<Vec<ProjectTool>, String> {
}
#[tauri::command]
pub fn add_global_tool(input: ProjectToolInput) -> Result<String, String> {
pub fn add_global_tool(input: GlobalToolInput) -> Result<String, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
@ -160,7 +74,7 @@ pub fn add_global_tool(input: ProjectToolInput) -> Result<String, String> {
}
#[tauri::command]
pub fn update_global_tool(id: String, input: ProjectToolInput) -> Result<(), String> {
pub fn update_global_tool(id: String, input: GlobalToolInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE global_tools

View File

@ -1,171 +0,0 @@
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HostApp {
pub id: String,
pub server_id: String,
pub name: String,
pub deploy_type: String,
pub service_name: Option<String>,
pub binary_path: Option<String>,
pub config_path: Option<String>,
pub log_path: Option<String>,
pub port: Option<String>,
pub start_cmd: Option<String>,
pub stop_cmd: Option<String>,
pub restart_cmd: Option<String>,
pub status_cmd: Option<String>,
pub notes: Option<String>,
pub sort_order: Option<i32>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveHostAppInput {
pub name: String,
pub deploy_type: String,
pub service_name: Option<String>,
pub binary_path: Option<String>,
pub config_path: Option<String>,
pub log_path: Option<String>,
pub port: Option<String>,
pub start_cmd: Option<String>,
pub stop_cmd: Option<String>,
pub restart_cmd: Option<String>,
pub status_cmd: Option<String>,
pub notes: Option<String>,
pub sort_order: Option<i32>,
}
const COLS: &str = "id, server_id, name, deploy_type, service_name, binary_path, config_path, log_path, port, start_cmd, stop_cmd, restart_cmd, status_cmd, notes, sort_order, created_at, updated_at";
fn row_to_app(row: &rusqlite::Row<'_>) -> rusqlite::Result<HostApp> {
Ok(HostApp {
id: row.get(0)?,
server_id: row.get(1)?,
name: row.get(2)?,
deploy_type: row.get(3)?,
service_name: row.get(4)?,
binary_path: row.get(5)?,
config_path: row.get(6)?,
log_path: row.get(7)?,
port: row.get(8)?,
start_cmd: row.get(9)?,
stop_cmd: row.get(10)?,
restart_cmd: row.get(11)?,
status_cmd: row.get(12)?,
notes: row.get(13)?,
sort_order: row.get(14)?,
created_at: row.get(15)?,
updated_at: row.get(16)?,
})
}
pub fn list_host_apps(conn: &Connection, server_id: &str) -> Vec<HostApp> {
let sql = format!("SELECT {COLS} FROM server_host_apps WHERE server_id = ?1 ORDER BY sort_order ASC, created_at ASC");
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map(params![server_id], row_to_app)
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn get_host_app(conn: &Connection, id: &str) -> Option<HostApp> {
let sql = format!("SELECT {COLS} FROM server_host_apps WHERE id = ?1");
conn.query_row(&sql, params![id], row_to_app).ok()
}
pub fn create_host_app(
conn: &Connection,
server_id: &str,
input: &SaveHostAppInput,
) -> Result<HostApp, String> {
let id = format!("ha-{}", uuid::Uuid::new_v4());
conn.execute(
"INSERT INTO server_host_apps (id, server_id, name, deploy_type, service_name, binary_path, config_path, log_path, port, start_cmd, stop_cmd, restart_cmd, status_cmd, notes, sort_order)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)",
params![id, server_id, input.name, input.deploy_type, input.service_name, input.binary_path,
input.config_path, input.log_path, input.port, input.start_cmd, input.stop_cmd,
input.restart_cmd, input.status_cmd, input.notes, input.sort_order.unwrap_or(0)],
).map_err(|e| e.to_string())?;
get_host_app(conn, &id).ok_or_else(|| "创建后查询失败".into())
}
pub fn update_host_app(
conn: &Connection,
id: &str,
input: &SaveHostAppInput,
) -> Result<HostApp, String> {
let rows = conn
.execute(
"UPDATE server_host_apps SET name=?1, deploy_type=?2, service_name=?3, binary_path=?4,
config_path=?5, log_path=?6, port=?7, start_cmd=?8, stop_cmd=?9, restart_cmd=?10,
status_cmd=?11, notes=?12, sort_order=?13, updated_at=datetime('now') WHERE id=?14",
params![
input.name,
input.deploy_type,
input.service_name,
input.binary_path,
input.config_path,
input.log_path,
input.port,
input.start_cmd,
input.stop_cmd,
input.restart_cmd,
input.status_cmd,
input.notes,
input.sort_order.unwrap_or(0),
id
],
)
.map_err(|e| e.to_string())?;
if rows == 0 {
return Err(format!("应用 {} 不存在", id));
}
get_host_app(conn, id).ok_or_else(|| "更新后查询失败".into())
}
pub fn delete_host_app_fn(conn: &Connection, id: &str) -> Result<(), String> {
conn.execute("DELETE FROM server_host_apps WHERE id=?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete_apps_by_server(conn: &Connection, server_id: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM server_host_apps WHERE server_id=?1",
params![server_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
// ── Tauri commands ───────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_host_apps(server_id: String) -> Result<Vec<HostApp>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
Ok(list_host_apps(&conn, &server_id))
}
#[tauri::command]
pub fn add_host_app(server_id: String, input: SaveHostAppInput) -> Result<HostApp, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
create_host_app(&conn, &server_id, &input)
}
#[tauri::command]
pub fn edit_host_app(id: String, input: SaveHostAppInput) -> Result<HostApp, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
update_host_app(&conn, &id, &input)
}
#[tauri::command]
pub fn delete_host_app(id: String) -> Result<(), String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
delete_host_app_fn(&conn, &id)
}

View File

@ -1,4 +1,3 @@
pub mod activity;
pub mod agent_infra;
pub mod api_keys;
pub mod blueprint;
@ -16,16 +15,15 @@ pub mod docker_apps;
pub mod git_ops;
pub mod git_repos;
pub mod gitea;
pub mod global_tools;
pub mod groups;
pub mod host_apps;
pub mod local_git;
pub mod mentor;
pub mod network;
pub mod onboarding;
pub mod project_scan;
pub mod project_tools;
pub mod projects;
pub mod proxy;
pub mod publish;
pub mod risk;
pub mod server_software;
pub mod servers;
@ -38,4 +36,3 @@ pub mod ssh_config;
pub mod tags;
pub mod templates;
pub mod toolchain;
pub mod user_todos;

View File

@ -199,14 +199,6 @@ pub(crate) fn dispatch_templates_with(
root: &Path,
sink: &dyn crate::WriteSink,
) -> Vec<TemplateResult> {
if crate::mcp_inject::files_managed_by_next(root) {
return vec![TemplateResult {
name: ".lian-jing/context.json".into(),
action: "skipped".into(),
detail: crate::mcp_inject::FILES_MANAGED_BY_NEXT_SKIP.into(),
warning: None,
}];
}
vec![
dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS, sink),
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK, sink),
@ -1251,21 +1243,6 @@ pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>
#[cfg(test)]
mod tests {
// 共存期防回写:项目文件已由新炼境接管时,接入包模板分发零写入
#[test]
fn dispatch_skips_projects_managed_by_next() {
let base = std::env::temp_dir().join(format!("lianjing_dispatch_guard_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
std::fs::create_dir_all(base.join(".blueprint")).unwrap();
std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"lian-jing-next"}"#).unwrap();
let results = super::dispatch_templates(base.to_str().unwrap());
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "skipped");
assert!(!base.join("AGENTS.md").exists() && !base.join(".claude").exists() && !base.join("lefthook.yml").exists());
let _ = std::fs::remove_dir_all(&base);
}
use super::*;
#[test]

View File

@ -1,7 +1,7 @@
use crate::commands::gitea::{self, RemovedRepoLink};
use crate::db::pool;
use crate::mcp_inject;
use crate::models::{ActivityLog, Project};
use crate::models::Project;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -93,13 +93,6 @@ pub struct UpdateProjectInput {
pub startup_commands: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProjectDetail {
#[serde(flatten)]
pub project: Project,
pub recent_activities: Vec<ActivityLog>,
}
#[tauri::command]
pub fn get_projects(
status: Option<String>,
@ -156,27 +149,11 @@ pub fn get_projects(
}
#[tauri::command]
pub fn get_project(id: String) -> Result<ProjectDetail, String> {
pub fn get_project(id: String) -> Result<Project, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let sql = format!("{PROJECT_VIEW_SQL} WHERE pw.id = ?1");
let project = conn
.query_row(&sql, params![id], Project::from_row)
.map_err(|_| format!("Project not found: {id}"))?;
let mut stmt = conn
.prepare(
"SELECT * FROM activity_log WHERE project_id = ?1 ORDER BY created_at DESC LIMIT 5",
)
.map_err(|e| e.to_string())?;
let activities: Vec<ActivityLog> = stmt
.query_map(params![id], ActivityLog::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
drop(stmt);
Ok(ProjectDetail {
project,
recent_activities: activities,
})
conn.query_row(&sql, params![id], Project::from_row)
.map_err(|_| format!("Project not found: {id}"))
}
// ── 登记本体(P8f)─────────────────────────────────────────────────────────────
@ -681,24 +658,13 @@ pub(crate) fn delete_project_records(
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
for table in [
"activity_log",
"project_tools",
"project_group_map",
"project_env_tools",
"deploy_commands",
] {
for table in ["project_group_map", "project_env_tools"] {
tx.execute(
&format!("DELETE FROM {table} WHERE project_id = ?1"),
params![id],
)
.map_err(|e| e.to_string())?;
}
tx.execute(
"DELETE FROM relations WHERE from_project = ?1 OR to_project = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
// 不解绑的话,后台 Gitea 轮询会继续把 commit 与 PR 事件写到已删除的项目 ID 下
let removed_link = gitea::remove_repo_link_records(&tx, id)?;

View File

@ -174,9 +174,8 @@ pub fn delete_server_fn(conn: &Connection, id: &str) -> Result<(), String> {
params![id],
)
.map_err(|e| e.to_string())?;
// 级联删除服务连接配置 + 宿主机应用
// 级联删除服务连接配置(server_host_apps 已撤销,旧库残留行无害)
let _ = super::service_config::delete_configs_by_server(conn, id);
let _ = super::host_apps::delete_apps_by_server(conn, id);
conn.execute("DELETE FROM servers WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())

View File

@ -1,5 +1,5 @@
use crate::db;
use crate::models::{DeployCommand, Space};
use crate::models::Space;
use uuid::Uuid;
// ── 空间管理 ────────────────────────────────────────────────
@ -81,93 +81,3 @@ pub fn set_current_space(id: String) -> Result<(), String> {
.map_err(|e| e.to_string())?;
Ok(())
}
// ── 部署命令管理 ────────────────────────────────────────────
#[tauri::command]
pub fn get_deploy_commands(project_id: String) -> Result<Vec<DeployCommand>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, project_id, name, command, run_in FROM deploy_commands WHERE project_id = ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params![project_id], DeployCommand::from_row)
.map_err(|e| e.to_string())?;
rows.map(|r| r.map_err(|e| e.to_string())).collect()
}
#[tauri::command]
pub fn add_deploy_command(
project_id: String,
name: String,
command: String,
run_in: String,
) -> Result<DeployCommand, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO deploy_commands (id, project_id, name, command, run_in) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![id, project_id, name, command, run_in],
)
.map_err(|e| e.to_string())?;
let cmd = conn
.query_row(
"SELECT id, project_id, name, command, run_in FROM deploy_commands WHERE id = ?1",
rusqlite::params![id],
DeployCommand::from_row,
)
.map_err(|e| e.to_string())?;
Ok(cmd)
}
#[tauri::command]
pub fn remove_deploy_command(id: String) -> Result<(), String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM deploy_commands WHERE id = ?1",
rusqlite::params![id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 执行部署命令,返回输出内容
#[tauri::command]
pub fn execute_deploy(
command: String,
run_in: String,
cwd: Option<String>,
) -> Result<String, String> {
let mut cmd = if run_in == "wsl" {
let mut c = crate::silent_cmd("wsl");
c.args(["-e", "bash", "-c", &command]);
c
} else {
let mut c = crate::silent_cmd("cmd");
c.args(["/c", &command]);
c
};
if let Some(dir) = cwd {
cmd.current_dir(&dir);
}
let output = cmd.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if out.status.success() {
Ok(if stdout.is_empty() {
"执行完成".to_string()
} else {
stdout
})
} else {
Err(if !stderr.is_empty() { stderr } else { stdout })
}
}
Err(e) => Err(format!("启动进程失败: {}", e)),
}
}

View File

@ -220,7 +220,7 @@ pub fn create_project_from_template(
}
// ── 1. 计算项目路径 ───────────────────────────────────────────────────────
let win_parent = crate::commands::publish::wsl_to_win(&parent_path);
let win_parent = crate::commands::local_git::wsl_to_win(&parent_path);
let project_win_path = format!("{}\\{}", win_parent.trim_end_matches('\\'), project_name);
let project_root = Path::new(&project_win_path);
@ -259,7 +259,7 @@ pub fn create_project_from_template(
if let Some(cmds) = &tmpl.init_commands {
let linux_project_path = if platform == "wsl" {
crate::commands::publish::extract_linux_path(&parent_path, &win_parent)
crate::commands::local_git::extract_linux_path(&parent_path, &win_parent)
.map(|p| format!("{}/{}", p.trim_end_matches('/'), project_name))
} else {
None

View File

@ -1,198 +0,0 @@
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct UserTodo {
pub id: i64,
pub project_id: String,
pub content: String,
pub done: bool,
pub sort_order: i64,
pub created_at: String,
}
// ── 业务函数(依赖注入,可单元测试)────────────────────────────────────────────
pub fn get_todos(conn: &Connection, project_id: &str) -> Vec<UserTodo> {
let mut stmt = match conn.prepare(
"SELECT id, project_id, content, done, sort_order, created_at
FROM user_project_todos
WHERE project_id = ?1
ORDER BY sort_order ASC, id ASC",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map(params![project_id], |row| {
Ok(UserTodo {
id: row.get(0)?,
project_id: row.get(1)?,
content: row.get(2)?,
done: row.get::<_, i64>(3)? != 0,
sort_order: row.get(4)?,
created_at: row.get(5)?,
})
})
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn add_todo(conn: &Connection, project_id: &str, content: &str) -> Result<UserTodo, String> {
let content = content.trim();
if content.is_empty() {
return Err("内容不能为空".to_string());
}
// sort_order = 当前最大值 + 1
let max_order: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sort_order), -1) FROM user_project_todos WHERE project_id = ?1",
params![project_id],
|row| row.get(0),
)
.unwrap_or(-1);
conn.execute(
"INSERT INTO user_project_todos (project_id, content, sort_order) VALUES (?1, ?2, ?3)",
params![project_id, content, max_order + 1],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
conn.query_row(
"SELECT id, project_id, content, done, sort_order, created_at
FROM user_project_todos WHERE id = ?1",
params![id],
|row| {
Ok(UserTodo {
id: row.get(0)?,
project_id: row.get(1)?,
content: row.get(2)?,
done: row.get::<_, i64>(3)? != 0,
sort_order: row.get(4)?,
created_at: row.get(5)?,
})
},
)
.map_err(|e| e.to_string())
}
pub fn toggle_todo(conn: &Connection, id: i64) -> Result<(), String> {
let rows = conn
.execute(
"UPDATE user_project_todos SET done = CASE WHEN done = 0 THEN 1 ELSE 0 END WHERE id = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
if rows == 0 {
return Err(format!("todo {} 不存在", id));
}
Ok(())
}
pub fn delete_todo(conn: &Connection, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM user_project_todos WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
// ── Tauri command 薄包装层(仅负责获取连接)────────────────────────────────────
#[tauri::command]
pub fn get_user_todos(project_id: String) -> Result<Vec<UserTodo>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
Ok(get_todos(&conn, &project_id))
}
#[tauri::command]
pub fn add_user_todo(project_id: String, content: String) -> Result<UserTodo, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
add_todo(&conn, &project_id, &content)
}
#[tauri::command]
pub fn toggle_user_todo(id: i64) -> Result<(), String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
toggle_todo(&conn, id)
}
#[tauri::command]
pub fn delete_user_todo(id: i64) -> Result<(), String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
delete_todo(&conn, id)
}
// ── 单元测试 ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE user_project_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
content TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);",
)
.unwrap();
conn
}
#[test]
fn test_add_and_get() {
let conn = setup();
add_todo(&conn, "proj-1", "买牛奶").unwrap();
add_todo(&conn, "proj-1", "写测试").unwrap();
let todos = get_todos(&conn, "proj-1");
assert_eq!(todos.len(), 2);
assert_eq!(todos[0].content, "买牛奶");
assert!(!todos[0].done);
}
#[test]
fn test_empty_content_rejected() {
let conn = setup();
assert!(add_todo(&conn, "proj-1", " ").is_err());
}
#[test]
fn test_toggle() {
let conn = setup();
let todo = add_todo(&conn, "proj-1", "测试条目").unwrap();
toggle_todo(&conn, todo.id).unwrap();
let todos = get_todos(&conn, "proj-1");
assert!(todos[0].done);
toggle_todo(&conn, todo.id).unwrap();
let todos = get_todos(&conn, "proj-1");
assert!(!todos[0].done);
}
#[test]
fn test_delete() {
let conn = setup();
let todo = add_todo(&conn, "proj-1", "待删除").unwrap();
delete_todo(&conn, todo.id).unwrap();
assert!(get_todos(&conn, "proj-1").is_empty());
}
#[test]
fn test_get_empty_returns_empty() {
let conn = setup();
assert!(get_todos(&conn, "不存在的项目").is_empty());
}
#[test]
fn test_sort_order_increments() {
let conn = setup();
add_todo(&conn, "proj-1", "第一").unwrap();
add_todo(&conn, "proj-1", "第二").unwrap();
let todos = get_todos(&conn, "proj-1");
assert!(todos[0].sort_order < todos[1].sort_order);
}
}

View File

@ -63,36 +63,11 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
PRIMARY KEY (project_id, group_id)
);
CREATE TABLE IF NOT EXISTS relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
to_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
relation_type TEXT,
note TEXT
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_tools (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS dev_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@ -131,8 +106,6 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
INSERT OR IGNORE INTO settings (key, value) VALUES ('editor', 'cursor');
INSERT OR IGNORE INTO settings (key, value) VALUES ('wsl_distro', 'Ubuntu');
INSERT OR IGNORE INTO settings (key, value) VALUES ('dev_mode', '0');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_id', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_secret', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190');
",
)
@ -147,14 +120,6 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
// 增量迁移:Git 多空间功能所需新表
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS git_accounts (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
avatar_url TEXT,
provider TEXT NOT NULL DEFAULT 'github',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@ -162,24 +127,10 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
is_current INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deploy_commands (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
command TEXT NOT NULL,
run_in TEXT NOT NULL DEFAULT 'windows'
);
",
)
.expect("migrate git spaces");
// 增量迁移:git_accounts 补 UNIQUE 索引(已存在则忽略)
let _ = conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_git_accounts_username ON git_accounts(username)",
[],
);
// 增量迁移:projects 表新增 Git 相关字段(已存在则忽略)
let _ = conn.execute("ALTER TABLE projects ADD COLUMN space_id TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT", []);
@ -341,77 +292,7 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
.expect("migrate project_group_map FK to project_workspaces");
}
// 迁移 project_tools / activity_log / project_env_tools:
// 将外键从 projects(id) 改为 project_workspaces(id)
let pt_sql: Option<String> = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_tools'",
[],
|row| row.get(0),
)
.ok();
if pt_sql
.as_deref()
.map(|s| s.contains("REFERENCES projects"))
.unwrap_or(false)
{
conn.execute_batch(
"
PRAGMA foreign_keys=OFF;
CREATE TABLE project_tools_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
INSERT OR IGNORE INTO project_tools_new
SELECT t.id, t.project_id, t.name, t.icon, t.exe_path, t.args, t.sort_order
FROM project_tools t
INNER JOIN project_workspaces pw ON pw.id = t.project_id;
DROP TABLE project_tools;
ALTER TABLE project_tools_new RENAME TO project_tools;
PRAGMA foreign_keys=ON;
",
)
.expect("migrate project_tools FK to project_workspaces");
}
let al_sql: Option<String> = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='activity_log'",
[],
|row| row.get(0),
)
.ok();
if al_sql
.as_deref()
.map(|s| s.contains("REFERENCES projects"))
.unwrap_or(false)
{
conn.execute_batch(
"
PRAGMA foreign_keys=OFF;
CREATE TABLE activity_log_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO activity_log_new (id, project_id, summary, created_at)
SELECT a.id, a.project_id, a.summary, a.created_at
FROM activity_log a
INNER JOIN project_workspaces pw ON pw.id = a.project_id;
DROP TABLE activity_log;
ALTER TABLE activity_log_new RENAME TO activity_log;
PRAGMA foreign_keys=ON;
",
)
.expect("migrate activity_log FK to project_workspaces");
}
// 迁移 project_env_tools:将外键从 projects(id) 改为 project_workspaces(id)
let pet_sql: Option<String> = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_env_tools'",
@ -574,21 +455,6 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
)
.expect("create envoy_dispositions table");
// 用户项目个人备忘(自由文本待办,Claude 不感知)
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS user_project_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
content TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
",
)
.expect("create user_project_todos table");
// 蓝图 Buff:记录哪些项目施加了 buff
conn.execute_batch(
"
@ -876,32 +742,6 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
.expect("create api_keys table");
let _ = conn.execute("ALTER TABLE api_keys ADD COLUMN credential_ref TEXT", []);
// 增量迁移:宿主机部署应用
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS server_host_apps (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
deploy_type TEXT NOT NULL DEFAULT 'systemd',
service_name TEXT,
binary_path TEXT,
config_path TEXT,
log_path TEXT,
port TEXT,
start_cmd TEXT,
stop_cmd TEXT,
restart_cmd TEXT,
status_cmd TEXT,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
",
)
.expect("create server_host_apps table");
// ── 源码库 ──────────────────────────────────────────────────────────
conn.execute_batch(
"

View File

@ -120,7 +120,6 @@ pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
}
use commands::{
activity::*,
blueprint::*,
buff::{apply_blueprint_buff, get_buff_status, list_buffed_projects, remove_blueprint_buff},
canvas::*,
@ -129,7 +128,9 @@ use commands::{
devtools::*,
git_ops::*,
git_repos::*,
global_tools::*,
groups::*,
local_git::*,
mentor::{
add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes,
get_startup_alerts, run_startup_health_scan,
@ -139,13 +140,11 @@ use commands::{
test_wsl_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::{
check_mcp_health, detect_editors, get_agent_autostart_status, get_agent_runtime_status,
get_settings, run_health_check, search_editor_path, set_agent_autostart, update_settings,
@ -306,9 +305,6 @@ pub fn run() {
delete_project,
scan_subfolders,
batch_create_projects,
// activity
get_activity,
add_activity,
// groups
get_groups,
create_group,
@ -328,13 +324,8 @@ pub fn run() {
get_agent_autostart_status,
set_agent_autostart,
run_health_check,
// project tools
get_project_tools,
add_project_tool,
update_project_tool,
remove_project_tool,
// global quick launch tools
launch_tool,
// global quick launch
get_global_tools,
add_global_tool,
update_global_tool,
@ -383,10 +374,6 @@ pub fn run() {
update_space,
delete_space,
set_current_space,
get_deploy_commands,
add_deploy_command,
remove_deploy_command,
execute_deploy,
// git repos registry
get_git_repos,
import_git_repos,
@ -462,11 +449,6 @@ pub fn run() {
add_project_note,
get_group_mentor_report,
get_startup_alerts,
// user todos
commands::user_todos::get_user_todos,
commands::user_todos::add_user_todo,
commands::user_todos::toggle_user_todo,
commands::user_todos::delete_user_todo,
// servers
commands::servers::get_servers,
commands::servers::add_server,
@ -512,11 +494,6 @@ pub fn run() {
commands::dev_variables::save_project_test_account,
commands::dev_variables::reveal_project_dev_variable,
commands::dev_variables::revoke_project_dev_variable,
// host apps
commands::host_apps::get_host_apps,
commands::host_apps::add_host_app,
commands::host_apps::edit_host_app,
commands::host_apps::delete_host_app,
// server service configs
commands::service_config::get_service_configs,
commands::service_config::add_service_config,

View File

@ -235,20 +235,6 @@ fn write_json_with(
.map_err(|error| format!("写入 {label} 失败: {error}"))
}
/// 新炼境(lian-jing-next)接管项目文件后写入 `.lian-jing/context.json` 的 `files_managed_by` 取值。
pub(crate) const FILES_MANAGED_BY_NEXT: &str = "lian-jing-next";
/// 共存期防回写守卫:项目文件已由新炼境接管时,旧炼境的模板分发、蓝图规则同步与 buff 轮询一律跳过,
/// 避免两边来回覆盖同一批托管文件。只读判断,文件缺失或不合法时视为未接管。
pub(crate) fn files_managed_by_next(root: &std::path::Path) -> bool {
std::fs::read_to_string(root.join(".lian-jing").join("context.json"))
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.is_some_and(|ctx| ctx.get("files_managed_by").and_then(Value::as_str) == Some(FILES_MANAGED_BY_NEXT))
}
pub(crate) const FILES_MANAGED_BY_NEXT_SKIP: &str = "项目文件已由新炼境接管(.lian-jing/context.json 的 files_managed_by),旧炼境不再分发";
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
#[cfg(test)]
@ -3637,26 +3623,6 @@ trust_level = "trusted"
);
}
#[test]
fn files_managed_by_next_guard_reads_context_and_survives_identity_rewrite() {
let base = std::env::temp_dir().join(format!("lianjing_files_guard_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
assert!(!files_managed_by_next(&base), "没有 context.json 视为未接管");
std::fs::write(
base.join(".lian-jing/context.json"),
r#"{"project_id":"p1","schema_version":1,"files_managed_by":"lian-jing-next"}"#,
)
.unwrap();
assert!(files_managed_by_next(&base));
// 旧炼境刷新身份时必须保留接管标记,否则守卫会被自己抹掉
write_canonical_context_at(&base, "p1").unwrap();
assert!(files_managed_by_next(&base), "身份合并写后接管标记仍在");
std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"other"}"#).unwrap();
assert!(!files_managed_by_next(&base));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() {
let base = std::env::temp_dir().join(format!(

View File

@ -70,25 +70,6 @@ impl Project {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ActivityLog {
pub id: i64,
pub project_id: String,
pub summary: String,
pub created_at: Option<String>,
}
impl ActivityLog {
pub fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get("id")?,
project_id: row.get("project_id")?,
summary: row.get("summary")?,
created_at: row.get("created_at")?,
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProductGroup {
pub id: String,
@ -146,27 +127,6 @@ impl Space {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DeployCommand {
pub id: String,
pub project_id: String,
pub name: String,
pub command: String,
pub run_in: String,
}
impl DeployCommand {
pub fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get("id")?,
project_id: row.get("project_id")?,
name: row.get("name")?,
command: row.get("command")?,
run_in: row.get("run_in")?,
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CicdConfig {
pub id: String,

View File

@ -1,14 +1,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, getGlobalTools, addGlobalTool, updateGlobalTool, removeGlobalTool, launchTool, type ProductGroup, type Project, type ProjectToolInput } from "../../lib/commands";
import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, getGlobalTools, launchTool, 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";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { GlobalToolsModal, GLOBAL_TOOLS_QUERY_KEY } from "./GlobalToolsModal";
import { StatusBadge } from "../ui/Badge";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useUIStore } from "../../store/ui";
@ -197,7 +196,6 @@ interface GroupBoardSectionProps {
}
function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [groupView, setGroupView] = useState<GroupView>("default");
const winMembers = members.filter(({ project: p }) => p.platform !== "wsl");
@ -266,7 +264,6 @@ function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
key={project.id}
project={project}
role={role ?? undefined}
onDeploy={setDeployTarget}
/>
))
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p>
@ -283,7 +280,6 @@ function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
key={project.id}
project={project}
role={role ?? undefined}
onDeploy={setDeployTarget}
/>
))
: <p className="text-xs text-gray-300 py-5 text-center">暂无</p>
@ -292,15 +288,6 @@ function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
</>
)}
{deployTarget && (
<DeployModal
projectId={deployTarget.id}
projectName={deployTarget.name}
projectPath={deployTarget.win_path ?? undefined}
projectWslPath={deployTarget.wsl_path ?? undefined}
onClose={() => setDeployTarget(null)}
/>
)}
</section>
);
}
@ -308,11 +295,10 @@ function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
/* ── ProjectRow ────────────────────────────────────────────────────────────── */
function ProjectRow({
project: p, role, onDeploy,
project: p, role,
}: {
project: Project;
role?: string;
onDeploy: (p: Project) => void;
}) {
return (
<div className="flex divide-x divide-gray-100 border-b border-gray-50 last:border-b-0">
@ -388,16 +374,6 @@ function ProjectRow({
{!p.prod_url && !p.staging_url && !p.server_note && !p.recent_focus && (
<p className="text-[11px] text-gray-300 italic">未配置部署信息</p>
)}
{/* 部署配置按钮 */}
<div className="mt-auto pt-1">
<button
onClick={() => onDeploy(p)}
className="w-full px-2.5 py-1.5 rounded-md text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors"
>
🚀 部署配置
</button>
</div>
</div>
</div>
);
@ -405,22 +381,13 @@ function ProjectRow({
/* ── GlobalQuickLaunchBar ──────────────────────────────────────────────────── */
const GLOBAL_QUERY_KEY = ["globalTools"];
const globalToolCmds = {
getTools: () => getGlobalTools(),
addTool: (input: ProjectToolInput) => addGlobalTool(input),
updateTool: (id: string, input: ProjectToolInput) => updateGlobalTool(id, input),
removeTool: (id: string) => removeGlobalTool(id),
};
function GlobalQuickLaunchBar() {
const showToast = useUIStore((s) => s.showToast);
const [showMgr, setShowMgr] = useState(false);
const [mgrDefaultAdd, setMgrDefaultAdd] = useState(false);
const { data: tools = [] } = useQuery({
queryKey: GLOBAL_QUERY_KEY,
queryKey: GLOBAL_TOOLS_QUERY_KEY,
queryFn: () => getGlobalTools(),
staleTime: 30000,
});
@ -477,12 +444,8 @@ function GlobalQuickLaunchBar() {
</svg>
</button>
{showMgr && (
<ProjectToolsModal
projectId="__global__"
projectName="日常应用"
<GlobalToolsModal
defaultOpenAdd={mgrDefaultAdd}
commands={globalToolCmds}
queryKey={GLOBAL_QUERY_KEY}
onClose={() => { setShowMgr(false); setMgrDefaultAdd(false); }}
/>
)}
@ -497,7 +460,6 @@ function StandaloneBoardSection({
}: {
standalone: Project[];
}) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
@ -585,7 +547,7 @@ function StandaloneBoardSection({
</div>
{winProjects.length > 0
? winProjects.map((p) => (
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
<ProjectRow key={p.id} project={p} />
))
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p>
}
@ -597,21 +559,12 @@ function StandaloneBoardSection({
</div>
{wslProjects.length > 0
? wslProjects.map((p) => (
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
<ProjectRow key={p.id} project={p} />
))
: <p className="text-xs text-gray-300 py-5 text-center">暂无</p>
}
</div>
{deployTarget && (
<DeployModal
projectId={deployTarget.id}
projectName={deployTarget.name}
projectPath={deployTarget.win_path ?? undefined}
projectWslPath={deployTarget.wsl_path ?? undefined}
onClose={() => setDeployTarget(null)}
/>
)}
</section>
);
}

View File

@ -1,2 +0,0 @@
// 已废弃,部署信息已迁移到 ServerManagerPanel + ProjectServerPanel
export {};

View File

@ -1,175 +0,0 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getDeployCommands,
addDeployCommand,
removeDeployCommand,
executeDeploy,
type DeployCommand,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
projectId: string;
projectName: string;
projectPath?: string; // Windows 路径(win_path)
projectWslPath?: string; // WSL 路径(wsl_path),作为 WSL 命令的 fallback cwd
onClose: () => void;
}
export function DeployModal({ projectId, projectName, projectPath, projectWslPath, onClose }: Props) {
const queryClient = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const [newName, setNewName] = useState("");
const [newCmd, setNewCmd] = useState("");
const [newRunIn, setNewRunIn] = useState<"windows" | "wsl">("windows");
// 正在执行的命令 id
const [runningId, setRunningId] = useState<string | null>(null);
// 执行输出
const [output, setOutput] = useState<{ id: string; text: string; ok: boolean } | null>(null);
const { data: commands = [] } = useQuery({
queryKey: ["deployCommands", projectId],
queryFn: () => getDeployCommands(projectId),
});
const addMutation = useMutation({
mutationFn: () => addDeployCommand(projectId, newName, newCmd, newRunIn),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["deployCommands", projectId] });
setNewName("");
setNewCmd("");
},
onError: (e) => showToast(`添加失败: ${e}`),
});
const removeMutation = useMutation({
mutationFn: (id: string) => removeDeployCommand(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["deployCommands", projectId] }),
});
async function handleRun(cmd: DeployCommand) {
setRunningId(cmd.id);
setOutput(null);
try {
// WSL 命令优先用 projectPath(Windows 路径,WSL 自动映射 /mnt/...)
// 若无 win_path,则将 wsl_path 通过 `cd && ` 前缀传入命令
let effectiveCommand = cmd.command;
let cwd: string | undefined = projectPath;
if (cmd.run_in === "wsl" && !projectPath && projectWslPath) {
effectiveCommand = `cd ${JSON.stringify(projectWslPath)} && ${cmd.command}`;
cwd = undefined;
}
const result = await executeDeploy(effectiveCommand, cmd.run_in, cwd);
setOutput({ id: cmd.id, text: result, ok: true });
} catch (e) {
setOutput({ id: cmd.id, text: String(e), ok: false });
} finally {
setRunningId(null);
}
}
const canAdd = newName.trim() && newCmd.trim();
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-zinc-900 rounded-xl shadow-2xl w-[580px] max-h-[80vh] flex flex-col border border-zinc-700">
{/* 标题 */}
<div className="px-5 py-4 border-b border-zinc-700 flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-white">部署命令</h2>
<p className="text-xs text-zinc-400 mt-0.5">{projectName}</p>
</div>
<button onClick={onClose} className="text-zinc-400 hover:text-white text-xl leading-none">×</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
{/* 已有命令列表 */}
{commands.length === 0 && (
<p className="text-zinc-500 text-sm text-center py-4">暂无部署命令</p>
)}
{commands.map((cmd) => (
<div key={cmd.id} className="bg-zinc-800 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-white text-sm font-medium flex-1">{cmd.name}</span>
<span className={`text-xs px-1.5 py-0.5 rounded font-mono ${
cmd.run_in === "wsl"
? "bg-green-900 text-green-300"
: "bg-blue-900 text-blue-300"
}`}>
{cmd.run_in.toUpperCase()}
</span>
<button
onClick={() => handleRun(cmd)}
disabled={runningId === cmd.id}
className="px-3 py-1 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-xs rounded transition-colors"
>
{runningId === cmd.id ? "执行中…" : "▶ 执行"}
</button>
<button
onClick={() => removeMutation.mutate(cmd.id)}
className="text-zinc-500 hover:text-red-400 text-sm transition-colors"
title="删除"
>
✕
</button>
</div>
<code className="block text-xs text-zinc-400 font-mono bg-zinc-900 rounded px-2 py-1.5 break-all">
{cmd.command}
</code>
{/* 执行输出 */}
{output?.id === cmd.id && (
<div className={`rounded px-2 py-1.5 text-xs font-mono whitespace-pre-wrap break-all max-h-32 overflow-y-auto ${
output.ok ? "bg-zinc-950 text-green-400" : "bg-red-950 text-red-300"
}`}>
{output.text}
</div>
)}
</div>
))}
</div>
{/* 添加新命令 */}
<div className="px-4 py-4 border-t border-zinc-700 space-y-2">
<p className="text-xs text-zinc-400 font-medium">添加命令</p>
<div className="flex gap-2">
<input
type="text"
placeholder="名称(如:启动服务)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1 bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-600"
/>
<select
value={newRunIn}
onChange={(e) => setNewRunIn(e.target.value as "windows" | "wsl")}
className="bg-zinc-800 text-white text-sm rounded-lg px-2 py-2 outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="windows">Windows</option>
<option value="wsl">WSL</option>
</select>
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="命令(如:docker compose up -d)"
value={newCmd}
onChange={(e) => setNewCmd(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canAdd && addMutation.mutate()}
className="flex-1 bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-600 font-mono"
/>
<button
onClick={() => addMutation.mutate()}
disabled={!canAdd || addMutation.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
>
添加
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -2,56 +2,38 @@ import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-dialog";
import {
getProjectTools,
addProjectTool,
updateProjectTool,
removeProjectTool,
type ProjectTool,
type ProjectToolInput,
getGlobalTools,
addGlobalTool,
updateGlobalTool,
removeGlobalTool,
type GlobalTool,
type GlobalToolInput,
} from "../../lib/commands";
export interface ToolCommands {
getTools: () => Promise<ProjectTool[]>;
addTool: (input: ProjectToolInput) => Promise<string>;
updateTool: (id: string, input: ProjectToolInput) => Promise<void>;
removeTool: (id: string) => Promise<void>;
}
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
export const GLOBAL_TOOLS_QUERY_KEY = ["globalTools"];
const COMMON_ICONS = ["🔧", "🎬", "🎨", "📷", "🗄️", "📮", "🌐", "📊", "🎵", "📐", "🔬", "⚙️", "📁", "🖨️", "🎮"];
const EMPTY: ProjectToolInput = { name: "", icon: "🔧", exe_path: "", args: "" };
const EMPTY: GlobalToolInput = { name: "", icon: "🔧", exe_path: "", args: "" };
interface Props {
projectId: string;
projectName: string;
onClose: () => void;
defaultOpenAdd?: boolean;
commands?: ToolCommands;
queryKey?: unknown[];
}
export function ProjectToolsModal({ projectId, projectName, onClose, defaultOpenAdd, commands, queryKey }: Props) {
export function GlobalToolsModal({ onClose, defaultOpenAdd }: Props) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const cmds: ToolCommands = commands ?? {
getTools: () => getProjectTools(projectId),
addTool: (input) => addProjectTool(projectId, input),
updateTool: (id, input) => updateProjectTool(id, input),
removeTool: (id) => removeProjectTool(id),
};
const resolvedKey = queryKey ?? ["projectTools", projectId];
const { data: tools = [], refetch } = useQuery({
queryKey: resolvedKey,
queryFn: () => cmds.getTools(),
queryKey: GLOBAL_TOOLS_QUERY_KEY,
queryFn: () => getGlobalTools(),
});
const [editing, setEditing] = useState<ProjectTool | null>(null); // null = 新增模式
const [form, setForm] = useState<ProjectToolInput>(EMPTY);
const [editing, setEditing] = useState<GlobalTool | null>(null); // null = 新增模式
const [form, setForm] = useState<GlobalToolInput>(EMPTY);
const [showForm, setShowForm] = useState(defaultOpenAdd ?? false);
const [saving, setSaving] = useState(false);
@ -61,13 +43,13 @@ export function ProjectToolsModal({ projectId, projectName, onClose, defaultOpen
setShowForm(true);
};
const openEdit = (tool: ProjectTool) => {
const openEdit = (tool: GlobalTool) => {
setEditing(tool);
setForm({ name: tool.name, icon: tool.icon, exe_path: tool.exe_path, args: tool.args ?? "" });
setShowForm(true);
};
const set = (k: keyof ProjectToolInput, v: string) =>
const set = (k: keyof GlobalToolInput, v: string) =>
setForm((f) => ({ ...f, [k]: v }));
const pickExe = async () => {
@ -93,18 +75,18 @@ export function ProjectToolsModal({ projectId, projectName, onClose, defaultOpen
if (!form.exe_path.trim()) { showToast("❌ 程序路径不能为空"); return; }
setSaving(true);
try {
const input: ProjectToolInput = {
const input: GlobalToolInput = {
...form,
args: form.args?.trim() || undefined,
sort_order: editing?.sort_order ?? tools.length,
};
if (editing) {
await cmds.updateTool(editing.id, input);
await updateGlobalTool(editing.id, input);
} else {
await cmds.addTool(input);
await addGlobalTool(input);
}
await refetch();
qc.invalidateQueries({ queryKey: resolvedKey });
qc.invalidateQueries({ queryKey: GLOBAL_TOOLS_QUERY_KEY });
setShowForm(false);
showToast(editing ? "已更新 ✓" : "已添加 ✓");
} catch (e) {
@ -116,9 +98,9 @@ export function ProjectToolsModal({ projectId, projectName, onClose, defaultOpen
const handleDelete = async (id: string) => {
try {
await cmds.removeTool(id);
await removeGlobalTool(id);
await refetch();
qc.invalidateQueries({ queryKey: resolvedKey });
qc.invalidateQueries({ queryKey: GLOBAL_TOOLS_QUERY_KEY });
} catch (e) {
showToast(`❌ ${e}`);
}
@ -127,7 +109,7 @@ export function ProjectToolsModal({ projectId, projectName, onClose, defaultOpen
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200";
return (
<Dialog title={`工具管理 · ${projectName}`} onClose={onClose} size="lg">
<Dialog title="工具管理 · 日常应用" onClose={onClose} size="lg">
<div className="px-6 py-4">
{/* 工具列表 */}

View File

@ -1,330 +0,0 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getHostApps, addHostApp, editHostApp, deleteHostApp,
type HostApp, type SaveHostAppInput,
} from "../../lib/commands";
const DEPLOY_TYPES = [
{ value: "systemd", label: "systemd 服务", color: "bg-green-100 text-green-700 border-green-200" },
{ value: "pm2", label: "PM2", color: "bg-blue-100 text-blue-700 border-blue-200" },
{ value: "supervisor", label: "Supervisor", color: "bg-purple-100 text-purple-700 border-purple-200" },
{ value: "screen", label: "Screen/Tmux", color: "bg-cyan-100 text-cyan-700 border-cyan-200" },
{ value: "manual", label: "手动启动", color: "bg-gray-100 text-gray-600 border-gray-200" },
];
function getTypeStyle(t: string) {
return DEPLOY_TYPES.find((d) => d.value === t)?.color ?? DEPLOY_TYPES[DEPLOY_TYPES.length - 1].color;
}
function getTypeLabel(t: string) {
return DEPLOY_TYPES.find((d) => d.value === t)?.label ?? t;
}
function CopyBtn({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={handleCopy}
className="shrink-0 px-1.5 py-0.5 rounded text-[11px] border border-gray-200 text-gray-400 hover:text-blue-500 hover:border-blue-300 hover:bg-blue-50 transition-colors">
{copied ? "已复制" : "复制"}
</button>
);
}
function CmdRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-900 rounded-md group">
<span className="text-[10px] text-gray-500 w-10 shrink-0 text-right">{label}</span>
<span className="text-[11px] text-green-400 font-mono flex-1 truncate">$ {value}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity">
<CopyBtn value={value} />
</span>
</div>
);
}
// ── 模态框表单 ────────────────────────────────────────────────────────────────
function HostAppModal({ initial, onSave, onClose, saving }: {
initial?: HostApp;
onSave: (input: SaveHostAppInput) => void;
onClose: () => void;
saving: boolean;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [deployType, setDeployType] = useState(initial?.deploy_type ?? "systemd");
const [serviceName, setServiceName] = useState(initial?.service_name ?? "");
const [binaryPath, setBinaryPath] = useState(initial?.binary_path ?? "");
const [configPath, setConfigPath] = useState(initial?.config_path ?? "");
const [logPath, setLogPath] = useState(initial?.log_path ?? "");
const [port, setPort] = useState(initial?.port ?? "");
const [startCmd, setStartCmd] = useState(initial?.start_cmd ?? "");
const [stopCmd, setStopCmd] = useState(initial?.stop_cmd ?? "");
const [restartCmd, setRestartCmd] = useState(initial?.restart_cmd ?? "");
const [statusCmd, setStatusCmd] = useState(initial?.status_cmd ?? "");
const [notes, setNotes] = useState(initial?.notes ?? "");
// systemd 时自动填充命令
const autoFillSystemd = (svc: string) => {
setServiceName(svc);
if (deployType === "systemd" && svc.trim()) {
const s = svc.trim();
if (!startCmd) setStartCmd(`systemctl start ${s}`);
if (!stopCmd) setStopCmd(`systemctl stop ${s}`);
if (!restartCmd) setRestartCmd(`systemctl restart ${s}`);
if (!statusCmd) setStatusCmd(`systemctl status ${s}`);
}
};
const handleSubmit = () => {
if (!name.trim()) return;
onSave({
name: name.trim(),
deploy_type: deployType,
service_name: serviceName.trim() || undefined,
binary_path: binaryPath.trim() || undefined,
config_path: configPath.trim() || undefined,
log_path: logPath.trim() || undefined,
port: port.trim() || undefined,
start_cmd: startCmd.trim() || undefined,
stop_cmd: stopCmd.trim() || undefined,
restart_cmd: restartCmd.trim() || undefined,
status_cmd: statusCmd.trim() || undefined,
notes: notes.trim() || undefined,
});
};
const isEdit = !!initial;
return (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-white rounded-xl border border-gray-200 shadow-xl w-[560px] max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-gray-800">{isEdit ? "编辑宿主机应用" : "添加宿主机应用"}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">x</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
<div className="grid grid-cols-[72px_1fr] gap-x-3 gap-y-2.5 items-center">
<label className="text-[11px] text-gray-400 text-right">名称 *</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="例: Nginx"
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300" />
<label className="text-[11px] text-gray-400 text-right">部署方式</label>
<select value={deployType} onChange={(e) => setDeployType(e.target.value)}
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 w-40">
{DEPLOY_TYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<label className="text-[11px] text-gray-400 text-right">服务名</label>
<input value={serviceName} onChange={(e) => autoFillSystemd(e.target.value)}
placeholder={deployType === "systemd" ? "例: nginx" : "例: app-name"}
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
<label className="text-[11px] text-gray-400 text-right">端口</label>
<input value={port} onChange={(e) => setPort(e.target.value)} placeholder="例: 8080"
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono w-28" />
<label className="text-[11px] text-gray-400 text-right">程序路径</label>
<input value={binaryPath} onChange={(e) => setBinaryPath(e.target.value)} placeholder="/usr/bin/..."
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
<label className="text-[11px] text-gray-400 text-right">配置路径</label>
<input value={configPath} onChange={(e) => setConfigPath(e.target.value)} placeholder="/etc/..."
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
<label className="text-[11px] text-gray-400 text-right">日志路径</label>
<input value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder="/var/log/..."
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
</div>
{/* 管理命令 */}
<div>
<span className="text-[11px] text-gray-500 font-medium">管理命令</span>
<div className="grid grid-cols-[72px_1fr] gap-x-3 gap-y-2 items-center mt-1.5">
<label className="text-[11px] text-gray-400 text-right">启动</label>
<input value={startCmd} onChange={(e) => setStartCmd(e.target.value)} placeholder="systemctl start ..."
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
<label className="text-[11px] text-gray-400 text-right">停止</label>
<input value={stopCmd} onChange={(e) => setStopCmd(e.target.value)} placeholder="systemctl stop ..."
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
<label className="text-[11px] text-gray-400 text-right">重启</label>
<input value={restartCmd} onChange={(e) => setRestartCmd(e.target.value)} placeholder="systemctl restart ..."
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
<label className="text-[11px] text-gray-400 text-right">状态</label>
<input value={statusCmd} onChange={(e) => setStatusCmd(e.target.value)} placeholder="systemctl status ..."
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 font-mono" />
</div>
</div>
<div className="grid grid-cols-[72px_1fr] gap-x-3 items-center">
<label className="text-[11px] text-gray-400 text-right">备注</label>
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="用途说明"
className="text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300" />
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t border-gray-100 bg-gray-50/50">
<button onClick={onClose}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors">
取消
</button>
<button onClick={handleSubmit} disabled={saving || !name.trim()}
className="px-4 py-1.5 rounded-md text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40">
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
);
}
// ── 应用卡片 ──────────────────────────────────────────────────────────────────
function HostAppCard({ app, onEdit, onDelete }: {
app: HostApp;
onEdit: () => void;
onDelete: () => void;
}) {
const [confirmDelete, setConfirmDelete] = useState(false);
const infos = [
app.service_name && { label: "服务名", value: app.service_name },
app.port && { label: "端口", value: app.port },
app.binary_path && { label: "程序", value: app.binary_path },
app.config_path && { label: "配置", value: app.config_path },
app.log_path && { label: "日志", value: app.log_path },
].filter(Boolean) as { label: string; value: string }[];
const cmds = [
app.start_cmd && { label: "启动", value: app.start_cmd },
app.stop_cmd && { label: "停止", value: app.stop_cmd },
app.restart_cmd && { label: "重启", value: app.restart_cmd },
app.status_cmd && { label: "状态", value: app.status_cmd },
].filter(Boolean) as { label: string; value: string }[];
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
{/* 标题 */}
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50/60 border-b border-gray-100">
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${getTypeStyle(app.deploy_type)}`}>
{getTypeLabel(app.deploy_type)}
</span>
<span className="text-xs font-semibold text-gray-800 flex-1 min-w-0 truncate">{app.name}</span>
{app.port && <span className="text-[11px] font-mono text-gray-400">:{app.port}</span>}
{app.notes && <span className="text-[11px] text-gray-400">{app.notes}</span>}
<button onClick={onEdit}
className="text-[11px] text-gray-400 hover:text-blue-500 transition-colors">编辑</button>
{!confirmDelete ? (
<button onClick={() => setConfirmDelete(true)}
className="text-[11px] text-gray-400 hover:text-red-500 transition-colors">删除</button>
) : (
<div className="flex gap-1">
<button onClick={onDelete}
className="text-[11px] text-red-500 hover:text-red-700 font-medium transition-colors">确认</button>
<button onClick={() => setConfirmDelete(false)}
className="text-[11px] text-gray-400 transition-colors">取消</button>
</div>
)}
</div>
<div className="px-3 py-2 space-y-2">
{/* 基础信息 */}
{infos.length > 0 && (
<div className="space-y-0.5">
{infos.map((info, idx) => (
<div key={idx} className="flex items-center gap-2 py-0.5 group">
<span className="text-[11px] text-gray-400 w-10 shrink-0 text-right">{info.label}</span>
<span className="text-xs font-mono text-gray-700 flex-1 min-w-0 truncate">{info.value}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity">
<CopyBtn value={info.value} />
</span>
</div>
))}
</div>
)}
{/* 管理命令 */}
{cmds.length > 0 && (
<div className="space-y-1">
{cmds.map((cmd, idx) => (
<CmdRow key={idx} label={cmd.label} value={cmd.value} />
))}
</div>
)}
</div>
</div>
);
}
// ── 主面板 ────────────────────────────────────────────────────────────────────
export function HostAppsPanel({ serverId }: { serverId: string }) {
const qc = useQueryClient();
const [showModal, setShowModal] = useState(false);
const [editingApp, setEditingApp] = useState<HostApp | null>(null);
const { data: apps = [] } = useQuery({
queryKey: ["host-apps", serverId],
queryFn: () => getHostApps(serverId),
});
const addMut = useMutation({
mutationFn: (input: SaveHostAppInput) => addHostApp(serverId, input),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["host-apps", serverId] }); setShowModal(false); },
});
const editMut = useMutation({
mutationFn: ({ id, input }: { id: string; input: SaveHostAppInput }) => editHostApp(id, input),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["host-apps", serverId] }); setEditingApp(null); },
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteHostApp(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["host-apps", serverId] }),
});
return (
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">宿主机应用</h4>
<button onClick={() => setShowModal(true)}
className="text-[11px] px-2 py-0.5 rounded border border-dashed border-gray-300 text-gray-400 hover:border-blue-300 hover:text-blue-500 transition-colors">
+ 添加
</button>
</div>
<div className="space-y-2">
{apps.map((app) => (
<HostAppCard
key={app.id}
app={app}
onEdit={() => setEditingApp(app)}
onDelete={() => deleteMut.mutate(app.id)}
/>
))}
{apps.length === 0 && (
<p className="text-[11px] text-gray-300 text-center py-3">
暂无宿主机应用,点击「+ 添加」记录 systemd 服务、PM2 进程等
</p>
)}
</div>
{showModal && (
<HostAppModal
onSave={(input) => addMut.mutate(input)}
onClose={() => setShowModal(false)}
saving={addMut.isPending}
/>
)}
{editingApp && (
<HostAppModal
initial={editingApp}
onSave={(input) => editMut.mutate({ id: editingApp.id, input })}
onClose={() => setEditingApp(null)}
saving={editMut.isPending}
/>
)}
</div>
);
}

View File

@ -1,15 +1,13 @@
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, gitCreateBranch, gitListBranches, gitCheckoutBranch, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
import { getSettings, openEditor, openTerminal, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, gitCreateBranch, gitListBranches, gitCheckoutBranch, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { ProjectEnvModal } from "./ProjectEnvModal";
import { CicdModal } from "./CicdModal";
import { BlueprintModal } from "./BlueprintModal";
import { BlueprintPromptModal } from "./BlueprintPromptModal";
import { MentorPanel } from "../mentor/MentorPanel";
import { UserTodoPanel } from "./UserTodoPanel";
import { ProjectServerPanel } from "./ProjectServerPanel";
import { ClaudeConfigModal } from "./ClaudeConfigModal";
import { ProjectDevVariablesPanel } from "./ProjectDevVariablesPanel";
@ -58,9 +56,6 @@ interface Props {
}
export function ProjectCard({ project: p }: Props) {
const [expanded, setExpanded] = useState(false);
const [showToolsMgr, setShowToolsMgr] = useState(false);
const [toolsMgrDefaultAdd, setToolsMgrDefaultAdd] = useState(false);
const [showEnvScan, setShowEnvScan] = useState(false);
const [showCicd, setShowCicd] = useState(false);
const [showBlueprint, setShowBlueprint] = useState(false);
@ -68,7 +63,6 @@ export function ProjectCard({ project: p }: Props) {
const [showMentor, setShowMentor] = useState(false);
const [showClaudeConfig, setShowClaudeConfig] = useState(false);
const [showDevVariables, setShowDevVariables] = useState(false);
const [showTodos, setShowTodos] = useState(false);
const [showServers, setShowServers] = useState(false);
const [showBranchMenu, setShowBranchMenu] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
@ -191,17 +185,6 @@ export function ProjectCard({ project: p }: Props) {
staleTime: 30000,
});
const { data: projectTools = [] } = useQuery({
queryKey: ["projectTools", p.id],
queryFn: () => getProjectTools(p.id),
});
const { data: acts } = useQuery({
queryKey: ["activity", p.id],
queryFn: () => getActivity(p.id, 5),
enabled: expanded,
});
// 当前已启用的编辑器列表
const activeEditors: { id: string; name: string; path: string }[] = (() => {
try {
@ -238,15 +221,6 @@ export function ProjectCard({ project: p }: Props) {
}
};
const handleLaunchTool = async (exePath: string, args?: string | null, name?: string) => {
try {
await launchTool(exePath, args ?? undefined);
showToast(`已启动 ${name ?? ""} ✓`);
} catch (e) {
showToast(`❌ ${e}`);
}
};
const copy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
showToast("已复制 ✓");
@ -555,32 +529,6 @@ export function ProjectCard({ project: p }: Props) {
</button>
</div>
)}
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5 shrink-0">快启</span>
{projectTools.length > 0 ? (
<>
{projectTools.map((tool) => (
<button key={tool.id} onClick={() => handleLaunchTool(tool.exe_path, tool.args, tool.name)}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-purple-50 hover:border-purple-300 hover:text-purple-800 transition-colors"
title={tool.exe_path}>
{tool.icon} {tool.name}
</button>
))}
<button
onClick={() => { setToolsMgrDefaultAdd(true); setShowToolsMgr(true); }}
className="px-2 py-1 rounded-md text-xs border border-dashed border-purple-200 text-purple-400 hover:border-purple-400 hover:text-purple-600 hover:bg-purple-50 transition-colors"
title="添加快启应用">
+
</button>
</>
) : (
<button
onClick={() => { setToolsMgrDefaultAdd(true); setShowToolsMgr(true); }}
className="px-2.5 py-1 rounded-md text-xs border border-dashed border-purple-200 text-purple-400 hover:border-purple-400 hover:text-purple-600 hover:bg-purple-50 transition-colors">
+ 配置快启应用
</button>
)}
</div>
</div>
)}
@ -603,7 +551,7 @@ export function ProjectCard({ project: p }: Props) {
<button
onClick={() => setShowMoreMenu((v) => !v)}
className={`px-2.5 py-1.5 rounded-md text-xs border transition-colors ${
showMoreMenu || showTodos || showServers
showMoreMenu || showServers
? "border-gray-300 bg-gray-100 text-gray-700"
: "border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
@ -616,11 +564,9 @@ export function ProjectCard({ project: p }: Props) {
{([
{ icon: "🤖", label: "Claude 自主性", action: () => setShowClaudeConfig(true) },
{ icon: "🔑", label: "开发变量", action: () => setShowDevVariables(true) },
{ icon: "📝", label: showTodos ? "收起备忘" : "个人备忘", action: () => setShowTodos((v) => !v) },
{ icon: "🖥️", label: showServers ? "收起服务器" : "关联服务器", action: () => setShowServers((v) => !v) },
{ icon: "🚀", label: "CI/CD 控制台", action: () => setShowCicd(true) },
{ icon: "🔬", label: "环境扫描", action: () => setShowEnvScan(true) },
{ icon: "🧰", label: "工具管理", action: () => setShowToolsMgr(true) },
]).map(({ icon, label, action }) => (
<button
key={label}
@ -640,22 +586,9 @@ export function ProjectCard({ project: p }: Props) {
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors">
编辑
</button>
<button onClick={() => setExpanded((v) => !v)}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors">
{expanded ? "收起 ▲" : "动态 ▼"}
</button>
</div>
</div>
{showToolsMgr && (
<ProjectToolsModal
projectId={p.id}
projectName={p.name}
defaultOpenAdd={toolsMgrDefaultAdd}
onClose={() => { setShowToolsMgr(false); setToolsMgrDefaultAdd(false); }}
/>
)}
{showEnvScan && (
<ProjectEnvModal
project={p}
@ -716,32 +649,8 @@ export function ProjectCard({ project: p }: Props) {
/>
)}
{/* 个人备忘 */}
{showTodos && <UserTodoPanel projectId={p.id} />}
{/* 关联服务器 */}
{showServers && <ProjectServerPanel projectId={p.id} />}
{/* Expanded activity */}
{expanded && (
<div className="border-t border-gray-50 pt-3">
<p className="text-xs font-medium text-gray-500 mb-2">最近动态</p>
{!acts ? (
<p className="text-xs text-gray-400">加载中…</p>
) : acts.length === 0 ? (
<p className="text-xs text-gray-400">暂无记录</p>
) : (
<ul className="flex flex-col gap-1">
{acts.map((a) => (
<li key={a.id} className="flex gap-2 text-xs text-gray-500">
<span className="text-gray-300 shrink-0">{(a.created_at ?? "").slice(5, 10)}</span>
<span>{a.summary}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@ -11,7 +11,6 @@ import {
import { useUIStore } from "../../store/ui";
import { ServiceConfigPanel } from "./ServiceConfigBlock";
import { HostAppsPanel } from "./HostAppsPanel";
function CopyBtn({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
@ -472,9 +471,6 @@ function ServerCard({ server, onEdit, onDelete, globalKeyPath }: {
{/* VS Code Remote */}
<VscodeRemoteBlock serverId={server.id} serverName={server.name} />
{/* 宿主机应用 */}
<HostAppsPanel serverId={server.id} />
{/* 服务连接配置(Linux 客户端层) */}
<ServiceConfigPanel serverId={server.id} scope="system" />

View File

@ -1,104 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getUserTodos, addUserTodo, toggleUserTodo, deleteUserTodo } from "../../lib/commands";
export function UserTodoPanel({ projectId }: { projectId: string }) {
const qc = useQueryClient();
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { data: todos = [] } = useQuery({
queryKey: ["user-todos", projectId],
queryFn: () => getUserTodos(projectId),
});
const addMut = useMutation({
mutationFn: (content: string) => addUserTodo(projectId, content),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["user-todos", projectId] });
setInput("");
},
});
const toggleMut = useMutation({
mutationFn: (id: number) => toggleUserTodo(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["user-todos", projectId] }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteUserTodo(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["user-todos", projectId] }),
});
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && input.trim()) {
addMut.mutate(input.trim());
}
};
const pending = todos.filter((t) => !t.done);
const done = todos.filter((t) => t.done);
return (
<div className="mt-2 border-t border-gray-50 pt-3">
{/* 输入框 */}
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="记点什么… 回车确认"
className="w-full text-xs px-2 py-1.5 rounded border border-gray-200 bg-gray-50
placeholder-gray-300 focus:outline-none focus:border-gray-400 mb-2"
/>
{/* 待办列表 */}
{pending.length === 0 && done.length === 0 && (
<p className="text-xs text-gray-300 text-center py-1">暂无备忘</p>
)}
<ul className="flex flex-col gap-0.5">
{pending.map((t) => (
<li key={t.id} className="flex items-center gap-2 group">
<button
onClick={() => toggleMut.mutate(t.id)}
className="w-3.5 h-3.5 rounded border border-gray-300 shrink-0
hover:border-blue-400 transition-colors"
/>
<span className="text-xs text-gray-600 flex-1 leading-relaxed">{t.content}</span>
<button
onClick={() => deleteMut.mutate(t.id)}
className="text-gray-200 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 text-xs shrink-0"
>
×
</button>
</li>
))}
{/* 已完成的条目折叠在末尾 */}
{done.map((t) => (
<li key={t.id} className="flex items-center gap-2 group">
<button
onClick={() => toggleMut.mutate(t.id)}
className="w-3.5 h-3.5 rounded border border-gray-200 bg-gray-100 shrink-0
flex items-center justify-center hover:border-gray-400 transition-colors"
>
<span className="text-gray-400 text-[9px] leading-none">✓</span>
</button>
<span className="text-xs text-gray-300 line-through flex-1 leading-relaxed">{t.content}</span>
<button
onClick={() => deleteMut.mutate(t.id)}
className="text-gray-200 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 text-xs shrink-0"
>
×
</button>
</li>
))}
</ul>
</div>
);
}

View File

@ -1 +0,0 @@
export {};

View File

@ -34,17 +34,6 @@ export interface Project {
updated_at?: string;
}
export interface ProjectDetail extends Project {
recent_activities: ActivityLog[];
}
export interface ActivityLog {
id: number;
project_id: string;
summary: string;
created_at?: string;
}
export interface ProductGroup {
id: string;
name: string;
@ -72,7 +61,7 @@ export const getProjects = (status?: string, spaceId?: string) =>
invoke<Project[]>("get_projects", { status, spaceId });
export const getProject = (id: string) =>
invoke<ProjectDetail>("get_project", { id });
invoke<Project>("get_project", { id });
export const createProject = (input: Omit<Project, "updated_at" | "workspace_id">) =>
invoke<void>("create_project", { input });
@ -100,14 +89,6 @@ export interface BatchProjectInput {
export const batchCreateProjects = (items: BatchProjectInput[]) =>
invoke<string[]>("batch_create_projects", { items });
// ── Activity ──────────────────────────────────────────────────────────────────
export const getActivity = (projectId: string, limit?: number) =>
invoke<ActivityLog[]>("get_activity", { projectId, limit });
export const addActivity = (projectId: string, summary: string) =>
invoke<void>("add_activity", { projectId, summary });
// ── Groups ────────────────────────────────────────────────────────────────────
export const getGroups = (spaceId?: string) => invoke<ProductGroup[]>("get_groups", { spaceId });
@ -222,11 +203,10 @@ export interface HealthReport {
export const runHealthCheck = () => invoke<HealthReport>("run_health_check");
// ── Project Tools ─────────────────────────────────────────────────────────────
// ── Global Quick Launch ───────────────────────────────────────────────────────
export interface ProjectTool {
export interface GlobalTool {
id: string;
project_id: string;
name: string;
icon: string;
exe_path: string;
@ -234,7 +214,7 @@ export interface ProjectTool {
sort_order: number;
}
export interface ProjectToolInput {
export interface GlobalToolInput {
name: string;
icon: string;
exe_path: string;
@ -242,30 +222,16 @@ export interface ProjectToolInput {
sort_order?: number;
}
export const getProjectTools = (projectId: string) =>
invoke<ProjectTool[]>("get_project_tools", { projectId });
export const addProjectTool = (projectId: string, input: ProjectToolInput) =>
invoke<string>("add_project_tool", { projectId, input });
export const updateProjectTool = (id: string, input: ProjectToolInput) =>
invoke<void>("update_project_tool", { id, input });
export const removeProjectTool = (id: string) =>
invoke<void>("remove_project_tool", { id });
export const launchTool = (exePath: string, args?: string) =>
invoke<void>("launch_tool", { exePath, args });
// ── Global Quick Launch ───────────────────────────────────────────────────────
export const getGlobalTools = () =>
invoke<ProjectTool[]>("get_global_tools");
invoke<GlobalTool[]>("get_global_tools");
export const addGlobalTool = (input: ProjectToolInput) =>
export const addGlobalTool = (input: GlobalToolInput) =>
invoke<string>("add_global_tool", { input });
export const updateGlobalTool = (id: string, input: ProjectToolInput) =>
export const updateGlobalTool = (id: string, input: GlobalToolInput) =>
invoke<void>("update_global_tool", { id, input });
export const removeGlobalTool = (id: string) =>
@ -414,14 +380,6 @@ export interface Space {
created_at?: string;
}
export interface DeployCommand {
id: string;
project_id: string;
name: string;
command: string;
run_in: "windows" | "wsl";
}
export const getSpaces = () =>
invoke<Space[]>("get_spaces");
@ -437,22 +395,6 @@ export const deleteSpace = (id: string) =>
export const setCurrentSpace = (id: string) =>
invoke<void>("set_current_space", { id });
export const getDeployCommands = (projectId: string) =>
invoke<DeployCommand[]>("get_deploy_commands", { projectId });
export const addDeployCommand = (
projectId: string,
name: string,
command: string,
runIn: "windows" | "wsl"
) => invoke<DeployCommand>("add_deploy_command", { projectId, name, command, runIn });
export const removeDeployCommand = (id: string) =>
invoke<void>("remove_deploy_command", { id });
export const executeDeploy = (command: string, runIn: "windows" | "wsl", cwd?: string) =>
invoke<string>("execute_deploy", { command, runIn, cwd });
// ── Git 操作 ──────────────────────────────────────────────────────────────────
export interface GitStatus {
@ -1171,29 +1113,6 @@ export const getProjectNotes = (projectId: string) =>
export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
invoke<void>("add_project_note", { projectId, content, source });
// ── 用户项目个人备忘 ──────────────────────────────────────────────────────────
export interface UserTodo {
id: number;
project_id: string;
content: string;
done: boolean;
sort_order: number;
created_at: string;
}
export const getUserTodos = (projectId: string) =>
invoke<UserTodo[]>("get_user_todos", { projectId });
export const addUserTodo = (projectId: string, content: string) =>
invoke<UserTodo>("add_user_todo", { projectId, content });
export const toggleUserTodo = (id: number) =>
invoke<void>("toggle_user_todo", { id });
export const deleteUserTodo = (id: number) =>
invoke<void>("delete_user_todo", { id });
// ── Servers ──────────────────────────────────────────────────────────────────
export interface QuickLink {
@ -1318,56 +1237,6 @@ export const editServiceConfig = (id: string, input: SaveServiceConfigInput) =>
export const deleteServiceConfig = (id: string) =>
invoke<void>("delete_service_config", { id });
// ── Host Apps(宿主机部署应用)────────────────────────────────────────────────
export interface HostApp {
id: string;
server_id: string;
name: string;
deploy_type: string;
service_name?: string;
binary_path?: string;
config_path?: string;
log_path?: string;
port?: string;
start_cmd?: string;
stop_cmd?: string;
restart_cmd?: string;
status_cmd?: string;
notes?: string;
sort_order?: number;
created_at?: string;
updated_at?: string;
}
export interface SaveHostAppInput {
name: string;
deploy_type: string;
service_name?: string;
binary_path?: string;
config_path?: string;
log_path?: string;
port?: string;
start_cmd?: string;
stop_cmd?: string;
restart_cmd?: string;
status_cmd?: string;
notes?: string;
sort_order?: number;
}
export const getHostApps = (serverId: string) =>
invoke<HostApp[]>("get_host_apps", { serverId });
export const addHostApp = (serverId: string, input: SaveHostAppInput) =>
invoke<HostApp>("add_host_app", { serverId, input });
export const editHostApp = (id: string, input: SaveHostAppInput) =>
invoke<HostApp>("edit_host_app", { id, input });
export const deleteHostApp = (id: string) =>
invoke<void>("delete_host_app", { id });
// ── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKey {