Compare commits
1 Commits
master
...
feat/featu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412ab8491f |
@ -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#承重墙
|
||||
- 决策与否决 → 各模块「决策记录」;终验否决理由 → 卡上 `- 终验:` 行
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
28
.blueprint/modules/feature-pruning.md
Normal file
28
.blueprint/modules/feature-pruning.md
Normal 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)
|
||||
@ -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;证据攒够后统筹会话一次性核对并出终验清单。
|
||||
@ -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` | 文件写入成功,格式合法 |
|
||||
|
||||
|
||||
@ -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` | 文件写入成功,格式合法 |
|
||||
|
||||
## 分发给子项目的方案
|
||||
|
||||
|
||||
@ -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/ 接入包分发模板
|
||||
|
||||
@ -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 后在其模块内立卡。
|
||||
|
||||
## 为什么(压缩背景 + 证据)
|
||||
|
||||
|
||||
@ -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 热点文件的可并行 |
|
||||
|
||||
95
docs/tasks/2026-10-04-feature-pruning.md
Normal file
95
docs/tasks/2026-10-04-feature-pruning.md
Normal 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 零残留。**未做**:旧库副本迁移实测(复制真实库的命令被拒),待人终验 |
|
||||
45
docs/templates/agents-meta-rules.md
vendored
45
docs/templates/agents-meta-rules.md
vendored
@ -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` 的契约路径还准确吗?
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)?;
|
||||
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
// 已废弃,部署信息已迁移到 ServerManagerPanel + ProjectServerPanel
|
||||
export {};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
{/* 工具列表 */}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export {};
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user