feat(feature-pruning): 撤销零数据/死代码功能(留撤盘点批次 1) #46

Open
lanrtop wants to merge 1 commits from feat/feature-pruning into master
27 changed files with 201 additions and 2014 deletions

View File

@ -56,7 +56,8 @@ modules:
- id: project-tools - id: project-tools
name: 项目工具链 name: 项目工具链
area: frontend area: frontend
status: done status: abandoned
note: "2026-10-04 feature-pruning B1 撤销项目级工具(project_tools 0 行);全局快启保留为 global_tools"
progress: 100 progress: 100
position: [500, 150] position: [500, 150]
@ -271,7 +272,8 @@ modules:
- id: user-project-todos - id: user-project-todos
name: 项目个人备忘 name: 项目个人备忘
area: frontend area: frontend
status: done status: abandoned
note: "2026-10-04 feature-pruning B1 撤销(1 条,表保留不删)"
progress: 100 progress: 100
position: [2000, 150] position: [2000, 150]
@ -329,14 +331,16 @@ modules:
- id: deploy-info - id: deploy-info
name: 项目部署信息 name: 项目部署信息
area: frontend area: frontend
status: done status: abandoned
note: "2026-10-04 feature-pruning B1 撤销部署命令(deploy_commands 0 行)与死文件;生产/测试地址展示保留"
progress: 100 progress: 100
position: [3000, 150] position: [3000, 150]
- id: host-apps - id: host-apps
name: 宿主机应用管理 name: 宿主机应用管理
area: frontend area: frontend
status: done status: abandoned
note: "2026-10-04 feature-pruning B1 撤销(server_host_apps 0 行)"
progress: 100 progress: 100
position: [2750, 600] position: [2750, 600]
@ -534,6 +538,15 @@ modules:
note: "两层模型(人持有的意图目录 .blueprint/catalog.md + 蓝图正文),书装意图、指向事实;卡侧 intent 钉版,agent 编译可判定 /goal,确认卡兼作最小任务简报,回执写进提交。IC1 设计已确认(最小文件优先方案);IC2–IC4 只新增文档、可与 P8 并行,门控与接线等 P8 收口后做,IC13 按实测数据决定去留。" note: "两层模型(人持有的意图目录 .blueprint/catalog.md + 蓝图正文),书装意图、指向事实;卡侧 intent 钉版,agent 编译可判定 /goal,确认卡兼作最小任务简报,回执写进提交。IC1 设计已确认(最小文件优先方案);IC2–IC4 只新增文档、可与 P8 并行,门控与接线等 P8 收口后做,IC13 按实测数据决定去留。"
position: [4000, 950] position: [4000, 950]
- id: feature-pruning
name: 功能留撤盘点
area: infra
status: in_progress
progress: 40
updated: 2026-10-04
note: "按管家准入与退出阶段性过滤冗余功能;台账 docs/tasks/2026-10-04-feature-pruning.md。B1 撤零数据/死代码 agent_verify 通过待人终验(PR),B2 产品组退役待设计。"
position: [4250, 950]
edges: edges:
- from: project-resource-broker - from: project-resource-broker
to: project-dev-variables to: project-dev-variables

View File

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

View File

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

View File

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

View File

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

View File

@ -6,9 +6,8 @@ use std::process::Command as StdCommand;
// ── 数据结构 ────────────────────────────────────────────────────────────────── // ── 数据结构 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ProjectTool { pub struct GlobalTool {
pub id: String, pub id: String,
pub project_id: String,
pub name: String, pub name: String,
pub icon: String, pub icon: String,
pub exe_path: String, pub exe_path: String,
@ -17,7 +16,7 @@ pub struct ProjectTool {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ProjectToolInput { pub struct GlobalToolInput {
pub name: String, pub name: String,
pub icon: String, pub icon: String,
pub exe_path: String, pub exe_path: String,
@ -25,94 +24,10 @@ pub struct ProjectToolInput {
pub sort_order: Option<i32>, pub sort_order: Option<i32>,
} }
// ── Tauri 命令 ──────────────────────────────────────────────────────────────── // ── 全局快启命令(写入 global_tools 表)────────────────────────────────────────
/// 获取某个项目的所有工具
#[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 约束)────────────────────────
#[tauri::command] #[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 conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@ -123,9 +38,8 @@ pub fn get_global_tools() -> Result<Vec<ProjectTool>, String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
.query_map([], |row| { .query_map([], |row| {
Ok(ProjectTool { Ok(GlobalTool {
id: row.get(0)?, id: row.get(0)?,
project_id: "__global__".to_string(),
name: row.get(1)?, name: row.get(1)?,
icon: row.get(2)?, icon: row.get(2)?,
exe_path: row.get(3)?, exe_path: row.get(3)?,
@ -140,7 +54,7 @@ pub fn get_global_tools() -> Result<Vec<ProjectTool>, String> {
} }
#[tauri::command] #[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 conn = pool().get().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
conn.execute( conn.execute(
@ -160,7 +74,7 @@ pub fn add_global_tool(input: ProjectToolInput) -> Result<String, String> {
} }
#[tauri::command] #[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())?; let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute( conn.execute(
"UPDATE global_tools "UPDATE global_tools

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -220,7 +220,7 @@ pub fn create_project_from_template(
} }
// ── 1. 计算项目路径 ─────────────────────────────────────────────────────── // ── 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_win_path = format!("{}\\{}", win_parent.trim_end_matches('\\'), project_name);
let project_root = Path::new(&project_win_path); 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 { if let Some(cmds) = &tmpl.init_commands {
let linux_project_path = if platform == "wsl" { 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)) .map(|p| format!("{}/{}", p.trim_end_matches('/'), project_name))
} else { } else {
None None

View File

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

View File

@ -63,36 +63,11 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
PRIMARY KEY (project_id, group_id) 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS dev_tools (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, 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 ('editor', 'cursor');
INSERT OR IGNORE INTO settings (key, value) VALUES ('wsl_distro', 'Ubuntu'); 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 ('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'); INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190');
", ",
) )
@ -147,14 +120,6 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
// 增量迁移:Git 多空间功能所需新表 // 增量迁移:Git 多空间功能所需新表
conn.execute_batch( 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 ( CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -162,24 +127,10 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
is_current INTEGER NOT NULL DEFAULT 0, is_current INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')) 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"); .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 相关字段(已存在则忽略) // 增量迁移:projects 表新增 Git 相关字段(已存在则忽略)
let _ = conn.execute("ALTER TABLE projects ADD COLUMN space_id TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN space_id TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN repo_url 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"); .expect("migrate project_group_map FK to project_workspaces");
} }
// 迁移 project_tools / activity_log / project_env_tools: // 迁移 project_env_tools:将外键从 projects(id) 改为 project_workspaces(id)
// 将外键从 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");
}
let pet_sql: Option<String> = conn let pet_sql: Option<String> = conn
.query_row( .query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_env_tools'", "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"); .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 // 蓝图 Buff:记录哪些项目施加了 buff
conn.execute_batch( conn.execute_batch(
" "
@ -876,32 +742,6 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
.expect("create api_keys table"); .expect("create api_keys table");
let _ = conn.execute("ALTER TABLE api_keys ADD COLUMN credential_ref TEXT", []); 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( conn.execute_batch(
" "

View File

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

View File

@ -70,25 +70,6 @@ impl Project {
} }
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ActivityLog {
pub id: i64,
pub project_id: String,
pub summary: String,
pub created_at: Option<String>,
}
impl ActivityLog {
pub fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get("id")?,
project_id: row.get("project_id")?,
summary: row.get("summary")?,
created_at: row.get("created_at")?,
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProductGroup { pub struct ProductGroup {
pub id: String, 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)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CicdConfig { pub struct CicdConfig {
pub id: String, pub id: String,

View File

@ -1,14 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useQueryClient } 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 { ProjectCard } from "./ProjectCard";
import { DeployModal } from "./DeployModal";
import { GroupResourceCanvas } from "./GroupResourceCanvas"; import { GroupResourceCanvas } from "./GroupResourceCanvas";
import { ManagePage } from "../manage/ManagePage"; import { ManagePage } from "../manage/ManagePage";
import { BatchAddModal } from "./BatchAddModal"; import { BatchAddModal } from "./BatchAddModal";
import { MountRepoModal } from "./MountRepoModal"; import { MountRepoModal } from "./MountRepoModal";
import { ProjectToolsModal } from "./ProjectToolsModal"; import { GlobalToolsModal, GLOBAL_TOOLS_QUERY_KEY } from "./GlobalToolsModal";
import { StatusBadge } from "../ui/Badge"; import { StatusBadge } from "../ui/Badge";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
@ -197,7 +196,6 @@ interface GroupBoardSectionProps {
} }
function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) { function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [groupView, setGroupView] = useState<GroupView>("default"); const [groupView, setGroupView] = useState<GroupView>("default");
const winMembers = members.filter(({ project: p }) => p.platform !== "wsl"); const winMembers = members.filter(({ project: p }) => p.platform !== "wsl");
@ -266,7 +264,6 @@ function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
key={project.id} key={project.id}
project={project} project={project}
role={role ?? undefined} role={role ?? undefined}
onDeploy={setDeployTarget}
/> />
)) ))
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p> : <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} key={project.id}
project={project} project={project}
role={role ?? undefined} role={role ?? undefined}
onDeploy={setDeployTarget}
/> />
)) ))
: <p className="text-xs text-gray-300 py-5 text-center">暂无</p> : <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> </section>
); );
} }
@ -308,11 +295,10 @@ function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
/* ── ProjectRow ────────────────────────────────────────────────────────────── */ /* ── ProjectRow ────────────────────────────────────────────────────────────── */
function ProjectRow({ function ProjectRow({
project: p, role, onDeploy, project: p, role,
}: { }: {
project: Project; project: Project;
role?: string; role?: string;
onDeploy: (p: Project) => void;
}) { }) {
return ( return (
<div className="flex divide-x divide-gray-100 border-b border-gray-50 last:border-b-0"> <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.prod_url && !p.staging_url && !p.server_note && !p.recent_focus && (
<p className="text-[11px] text-gray-300 italic">未配置部署信息</p> <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>
</div> </div>
); );
@ -405,22 +381,13 @@ function ProjectRow({
/* ── GlobalQuickLaunchBar ──────────────────────────────────────────────────── */ /* ── 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() { function GlobalQuickLaunchBar() {
const showToast = useUIStore((s) => s.showToast); const showToast = useUIStore((s) => s.showToast);
const [showMgr, setShowMgr] = useState(false); const [showMgr, setShowMgr] = useState(false);
const [mgrDefaultAdd, setMgrDefaultAdd] = useState(false); const [mgrDefaultAdd, setMgrDefaultAdd] = useState(false);
const { data: tools = [] } = useQuery({ const { data: tools = [] } = useQuery({
queryKey: GLOBAL_QUERY_KEY, queryKey: GLOBAL_TOOLS_QUERY_KEY,
queryFn: () => getGlobalTools(), queryFn: () => getGlobalTools(),
staleTime: 30000, staleTime: 30000,
}); });
@ -477,12 +444,8 @@ function GlobalQuickLaunchBar() {
</svg> </svg>
</button> </button>
{showMgr && ( {showMgr && (
<ProjectToolsModal <GlobalToolsModal
projectId="__global__"
projectName="日常应用"
defaultOpenAdd={mgrDefaultAdd} defaultOpenAdd={mgrDefaultAdd}
commands={globalToolCmds}
queryKey={GLOBAL_QUERY_KEY}
onClose={() => { setShowMgr(false); setMgrDefaultAdd(false); }} onClose={() => { setShowMgr(false); setMgrDefaultAdd(false); }}
/> />
)} )}
@ -497,7 +460,6 @@ function StandaloneBoardSection({
}: { }: {
standalone: Project[]; standalone: Project[];
}) { }) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()); const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -585,7 +547,7 @@ function StandaloneBoardSection({
</div> </div>
{winProjects.length > 0 {winProjects.length > 0
? winProjects.map((p) => ( ? 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> : <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p>
} }
@ -597,21 +559,12 @@ function StandaloneBoardSection({
</div> </div>
{wslProjects.length > 0 {wslProjects.length > 0
? wslProjects.map((p) => ( ? 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> : <p className="text-xs text-gray-300 py-5 text-center">暂无</p>
} }
</div> </div>
{deployTarget && (
<DeployModal
projectId={deployTarget.id}
projectName={deployTarget.name}
projectPath={deployTarget.win_path ?? undefined}
projectWslPath={deployTarget.wsl_path ?? undefined}
onClose={() => setDeployTarget(null)}
/>
)}
</section> </section>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,13 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge"; import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { ProjectEnvModal } from "./ProjectEnvModal"; import { ProjectEnvModal } from "./ProjectEnvModal";
import { CicdModal } from "./CicdModal"; import { CicdModal } from "./CicdModal";
import { BlueprintModal } from "./BlueprintModal"; import { BlueprintModal } from "./BlueprintModal";
import { BlueprintPromptModal } from "./BlueprintPromptModal"; import { BlueprintPromptModal } from "./BlueprintPromptModal";
import { MentorPanel } from "../mentor/MentorPanel"; import { MentorPanel } from "../mentor/MentorPanel";
import { UserTodoPanel } from "./UserTodoPanel";
import { ProjectServerPanel } from "./ProjectServerPanel"; import { ProjectServerPanel } from "./ProjectServerPanel";
import { ClaudeConfigModal } from "./ClaudeConfigModal"; import { ClaudeConfigModal } from "./ClaudeConfigModal";
import { ProjectDevVariablesPanel } from "./ProjectDevVariablesPanel"; import { ProjectDevVariablesPanel } from "./ProjectDevVariablesPanel";
@ -58,9 +56,6 @@ interface Props {
} }
export function ProjectCard({ project: p }: 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 [showEnvScan, setShowEnvScan] = useState(false);
const [showCicd, setShowCicd] = useState(false); const [showCicd, setShowCicd] = useState(false);
const [showBlueprint, setShowBlueprint] = useState(false); const [showBlueprint, setShowBlueprint] = useState(false);
@ -68,7 +63,6 @@ export function ProjectCard({ project: p }: Props) {
const [showMentor, setShowMentor] = useState(false); const [showMentor, setShowMentor] = useState(false);
const [showClaudeConfig, setShowClaudeConfig] = useState(false); const [showClaudeConfig, setShowClaudeConfig] = useState(false);
const [showDevVariables, setShowDevVariables] = useState(false); const [showDevVariables, setShowDevVariables] = useState(false);
const [showTodos, setShowTodos] = useState(false);
const [showServers, setShowServers] = useState(false); const [showServers, setShowServers] = useState(false);
const [showBranchMenu, setShowBranchMenu] = useState(false); const [showBranchMenu, setShowBranchMenu] = useState(false);
const [newBranchName, setNewBranchName] = useState(""); const [newBranchName, setNewBranchName] = useState("");
@ -191,17 +185,6 @@ export function ProjectCard({ project: p }: Props) {
staleTime: 30000, 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 }[] = (() => { const activeEditors: { id: string; name: string; path: string }[] = (() => {
try { 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) => { const copy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {}); navigator.clipboard.writeText(text).catch(() => {});
showToast("已复制 ✓"); showToast("已复制 ✓");
@ -555,32 +529,6 @@ export function ProjectCard({ project: p }: Props) {
</button> </button>
</div> </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> </div>
)} )}
@ -603,7 +551,7 @@ export function ProjectCard({ project: p }: Props) {
<button <button
onClick={() => setShowMoreMenu((v) => !v)} onClick={() => setShowMoreMenu((v) => !v)}
className={`px-2.5 py-1.5 rounded-md text-xs border transition-colors ${ 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-300 bg-gray-100 text-gray-700"
: "border-gray-200 text-gray-500 hover:bg-gray-50" : "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: "Claude 自主性", action: () => setShowClaudeConfig(true) },
{ icon: "🔑", label: "开发变量", action: () => setShowDevVariables(true) }, { icon: "🔑", label: "开发变量", action: () => setShowDevVariables(true) },
{ icon: "📝", label: showTodos ? "收起备忘" : "个人备忘", action: () => setShowTodos((v) => !v) },
{ icon: "🖥️", label: showServers ? "收起服务器" : "关联服务器", action: () => setShowServers((v) => !v) }, { icon: "🖥️", label: showServers ? "收起服务器" : "关联服务器", action: () => setShowServers((v) => !v) },
{ icon: "🚀", label: "CI/CD 控制台", action: () => setShowCicd(true) }, { icon: "🚀", label: "CI/CD 控制台", action: () => setShowCicd(true) },
{ icon: "🔬", label: "环境扫描", action: () => setShowEnvScan(true) }, { icon: "🔬", label: "环境扫描", action: () => setShowEnvScan(true) },
{ icon: "🧰", label: "工具管理", action: () => setShowToolsMgr(true) },
]).map(({ icon, label, action }) => ( ]).map(({ icon, label, action }) => (
<button <button
key={label} 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"> 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>
<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>
</div> </div>
{showToolsMgr && (
<ProjectToolsModal
projectId={p.id}
projectName={p.name}
defaultOpenAdd={toolsMgrDefaultAdd}
onClose={() => { setShowToolsMgr(false); setToolsMgrDefaultAdd(false); }}
/>
)}
{showEnvScan && ( {showEnvScan && (
<ProjectEnvModal <ProjectEnvModal
project={p} project={p}
@ -716,32 +649,8 @@ export function ProjectCard({ project: p }: Props) {
/> />
)} )}
{/* 个人备忘 */}
{showTodos && <UserTodoPanel projectId={p.id} />}
{/* 关联服务器 */} {/* 关联服务器 */}
{showServers && <ProjectServerPanel 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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -34,17 +34,6 @@ export interface Project {
updated_at?: string; 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 { export interface ProductGroup {
id: string; id: string;
name: string; name: string;
@ -72,7 +61,7 @@ export const getProjects = (status?: string, spaceId?: string) =>
invoke<Project[]>("get_projects", { status, spaceId }); invoke<Project[]>("get_projects", { status, spaceId });
export const getProject = (id: string) => 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">) => export const createProject = (input: Omit<Project, "updated_at" | "workspace_id">) =>
invoke<void>("create_project", { input }); invoke<void>("create_project", { input });
@ -100,14 +89,6 @@ export interface BatchProjectInput {
export const batchCreateProjects = (items: BatchProjectInput[]) => export const batchCreateProjects = (items: BatchProjectInput[]) =>
invoke<string[]>("batch_create_projects", { items }); 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 ──────────────────────────────────────────────────────────────────── // ── Groups ────────────────────────────────────────────────────────────────────
export const getGroups = (spaceId?: string) => invoke<ProductGroup[]>("get_groups", { spaceId }); 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"); export const runHealthCheck = () => invoke<HealthReport>("run_health_check");
// ── Project Tools ───────────────────────────────────────────────────────────── // ── Global Quick Launch ───────────────────────────────────────────────────────
export interface ProjectTool { export interface GlobalTool {
id: string; id: string;
project_id: string;
name: string; name: string;
icon: string; icon: string;
exe_path: string; exe_path: string;
@ -234,7 +214,7 @@ export interface ProjectTool {
sort_order: number; sort_order: number;
} }
export interface ProjectToolInput { export interface GlobalToolInput {
name: string; name: string;
icon: string; icon: string;
exe_path: string; exe_path: string;
@ -242,30 +222,16 @@ export interface ProjectToolInput {
sort_order?: number; 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) => export const launchTool = (exePath: string, args?: string) =>
invoke<void>("launch_tool", { exePath, args }); invoke<void>("launch_tool", { exePath, args });
// ── Global Quick Launch ───────────────────────────────────────────────────────
export const getGlobalTools = () => 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 }); 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 }); invoke<void>("update_global_tool", { id, input });
export const removeGlobalTool = (id: string) => export const removeGlobalTool = (id: string) =>
@ -414,14 +380,6 @@ export interface Space {
created_at?: string; created_at?: string;
} }
export interface DeployCommand {
id: string;
project_id: string;
name: string;
command: string;
run_in: "windows" | "wsl";
}
export const getSpaces = () => export const getSpaces = () =>
invoke<Space[]>("get_spaces"); invoke<Space[]>("get_spaces");
@ -437,22 +395,6 @@ export const deleteSpace = (id: string) =>
export const setCurrentSpace = (id: string) => export const setCurrentSpace = (id: string) =>
invoke<void>("set_current_space", { id }); 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 操作 ────────────────────────────────────────────────────────────────── // ── Git 操作 ──────────────────────────────────────────────────────────────────
export interface GitStatus { export interface GitStatus {
@ -1171,29 +1113,6 @@ export const getProjectNotes = (projectId: string) =>
export const addProjectNote = (projectId: string, content: string, source: string = "manual") => export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
invoke<void>("add_project_note", { projectId, content, source }); 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 ────────────────────────────────────────────────────────────────── // ── Servers ──────────────────────────────────────────────────────────────────
export interface QuickLink { export interface QuickLink {
@ -1318,56 +1237,6 @@ export const editServiceConfig = (id: string, input: SaveServiceConfigInput) =>
export const deleteServiceConfig = (id: string) => export const deleteServiceConfig = (id: string) =>
invoke<void>("delete_service_config", { id }); 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 ───────────────────────────────────────────────────────────────── // ── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKey { export interface ApiKey {