use crate::db; use rusqlite::params; use serde::{Deserialize, Serialize}; use std::path::Path; use uuid::Uuid; // ── 数据结构 ────────────────────────────────────────────────────────────────── #[derive(Serialize, Deserialize, Clone)] pub struct TemplateFile { pub id: String, pub template_id: String, pub path: String, pub content: String, } #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ProjectTemplate { pub id: String, pub name: String, pub description: Option, pub platform: String, // "win" | "wsl" | "both" pub tech_stack: Option, pub init_commands: Option, pub is_builtin: bool, pub created_at: Option, #[serde(default)] pub files: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct TemplateFileInput { pub path: String, pub content: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveTemplateInput { pub id: Option, pub name: String, pub description: Option, pub platform: String, pub tech_stack: Option, pub init_commands: Option, pub files: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateProjectResult { pub project_id: String, pub logs: Vec, } // ── Tauri 命令 ──────────────────────────────────────────────────────────────── #[tauri::command] pub fn list_templates() -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at FROM project_templates ORDER BY is_builtin DESC, name", ) .map_err(|e| e.to_string())?; let templates = stmt .query_map([], |row| { Ok(ProjectTemplate { id: row.get(0)?, name: row.get(1)?, description: row.get(2)?, platform: row.get(3)?, tech_stack: row.get(4)?, init_commands: row.get(5)?, is_builtin: row.get::<_, i64>(6)? == 1, created_at: row.get(7)?, files: vec![], }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(templates) } #[tauri::command] pub fn get_template(id: String) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut tmpl = conn .query_row( "SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at FROM project_templates WHERE id = ?1", params![id], |row| { Ok(ProjectTemplate { id: row.get(0)?, name: row.get(1)?, description: row.get(2)?, platform: row.get(3)?, tech_stack: row.get(4)?, init_commands: row.get(5)?, is_builtin: row.get::<_, i64>(6)? == 1, created_at: row.get(7)?, files: vec![], }) }, ) .map_err(|e| format!("模板不存在: {e}"))?; let mut file_stmt = conn .prepare( "SELECT id, template_id, path, content FROM project_template_files WHERE template_id = ?1 ORDER BY path", ) .map_err(|e| e.to_string())?; tmpl.files = file_stmt .query_map(params![id], |row| { Ok(TemplateFile { id: row.get(0)?, template_id: row.get(1)?, path: row.get(2)?, content: row.get(3)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(tmpl) } #[tauri::command] pub fn save_template(input: SaveTemplateInput) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string()); conn.execute( "INSERT INTO project_templates (id, name, description, platform, tech_stack, init_commands, is_builtin) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, platform = excluded.platform, tech_stack = excluded.tech_stack, init_commands = excluded.init_commands", params![id, input.name, input.description, input.platform, input.tech_stack, input.init_commands], ) .map_err(|e| e.to_string())?; // 全量替换文件列表 conn.execute( "DELETE FROM project_template_files WHERE template_id = ?1", params![id], ) .map_err(|e| e.to_string())?; for file in &input.files { let file_id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO project_template_files (id, template_id, path, content) VALUES (?1, ?2, ?3, ?4)", params![file_id, id, file.path, file.content], ) .map_err(|e| e.to_string())?; } Ok(id) } #[tauri::command] pub fn delete_template(id: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute( "DELETE FROM project_templates WHERE id = ?1 AND is_builtin = 0", params![id], ) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn create_project_from_template( template_id: String, parent_path: String, project_name: String, platform: String, space_id: Option, ) -> Result { let tmpl = get_template(template_id)?; let mut logs: Vec = Vec::new(); // ── 1. 计算项目路径 ─────────────────────────────────────────────────────── let win_parent = crate::commands::publish::wsl_to_win(&parent_path); let project_win_path = format!( "{}\\{}", win_parent.trim_end_matches('\\'), project_name ); let project_root = Path::new(&project_win_path); if project_root.exists() { return Err(format!("目录已存在: {}", project_win_path)); } // ── 2. 创建根目录 ───────────────────────────────────────────────────────── std::fs::create_dir_all(project_root) .map_err(|e| format!("创建目录失败: {e}"))?; logs.push(format!("📁 已创建目录: {}", project_win_path)); // ── 3. 写入模板文件 ─────────────────────────────────────────────────────── for file in &tmpl.files { if file.path.trim().is_empty() { continue; } let content = apply_vars(&file.content, &project_name); let file_path = project_root.join(&file.path); if let Some(parent) = file_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("创建子目录失败 {}: {e}", file.path))?; } std::fs::write(&file_path, content) .map_err(|e| format!("写入 {} 失败: {e}", file.path))?; logs.push(format!("📄 {}", file.path)); } // ── 4. 执行初始化命令 ───────────────────────────────────────────────────── if let Some(cmds) = &tmpl.init_commands { let linux_project_path = if platform == "wsl" { crate::commands::publish::extract_linux_path(&parent_path, &win_parent) .map(|p| format!("{}/{}", p.trim_end_matches('/'), project_name)) } else { None }; for line in cmds.lines() { let cmd = line.trim(); if cmd.is_empty() || cmd.starts_with('#') { continue; } logs.push(format!("⚙ 执行: {}", cmd)); let output = if platform == "wsl" { let lp = linux_project_path .as_deref() .unwrap_or(&project_win_path); std::process::Command::new("wsl") .args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)]) .output() } else { std::process::Command::new("cmd") .args(["/c", &format!("chcp 65001 > nul && {}", cmd)]) .current_dir(&project_win_path) .output() }; match output { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if !stdout.is_empty() { logs.push(stdout); } if !stderr.is_empty() { logs.push(format!("⚠ {}", stderr)); } if out.status.success() { logs.push(" ✓".to_string()); } else { logs.push(format!(" ✗ 退出码: {}", out.status)); } } Err(e) => { logs.push(format!(" ✗ 启动失败: {e}")); } } } } // ── 5. 注册到数据库 ─────────────────────────────────────────────────────── let conn = db::pool().get().map_err(|e| e.to_string())?; let profile_id = Uuid::new_v4().to_string(); let workspace_id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO project_profiles (id, name, description, type, status, priority, tech_stack) VALUES (?1, ?2, ?3, 'app', 'active', 'mid', ?4)", params![profile_id, project_name, tmpl.description, tmpl.tech_stack], ) .map_err(|e| e.to_string())?; let is_wsl = platform == "wsl"; let (wsl_path, win_path_col): (Option<&str>, Option<&str>) = if is_wsl { (Some(&project_win_path), None) } else { (None, Some(&project_win_path)) }; // 存储时统一用 "windows"(与其他创建路径保持一致) let stored_platform = if is_wsl { "wsl" } else { "windows" }; conn.execute( "INSERT INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![workspace_id, profile_id, space_id, wsl_path, win_path_col, stored_platform], ) .map_err(|e| e.to_string())?; logs.push(format!("✅ 项目已注册,ID: {}", workspace_id)); Ok(CreateProjectResult { project_id: workspace_id, logs, }) } // ── 变量替换 ────────────────────────────────────────────────────────────────── fn apply_vars(content: &str, project_name: &str) -> String { let kebab = project_name .to_lowercase() .replace(' ', "-") .replace('_', "-"); let snake = project_name .to_lowercase() .replace(' ', "_") .replace('-', "_"); content .replace("{{project_name}}", project_name) .replace("{{project_name_kebab}}", &kebab) .replace("{{project_name_snake}}", &snake) } // ── 内置模板种子数据 ────────────────────────────────────────────────────────── pub fn seed_builtin_templates() { let conn = match db::pool().get() { Ok(c) => c, Err(_) => return, }; // 已有内置模板则跳过 let count: i64 = conn .query_row( "SELECT COUNT(*) FROM project_templates WHERE is_builtin = 1", [], |row| row.get(0), ) .unwrap_or(0); if count > 0 { return; } struct Tpl { id: &'static str, name: &'static str, description: &'static str, platform: &'static str, tech_stack: &'static str, init_commands: &'static str, files: Vec<(&'static str, &'static str)>, } let templates = vec![ Tpl { id: "builtin-pnpm-monorepo", name: "pnpm Monorepo", description: "多包 monorepo,含 apps/ 与 packages/ 目录", platform: "both", tech_stack: "Node,pnpm", init_commands: "pnpm install", files: vec![ ("package.json", r#"{ "name": "{{project_name_kebab}}", "private": true, "scripts": { "dev": "pnpm -r --filter './apps/**' dev", "build": "pnpm -r --filter './apps/**' build", "lint": "pnpm -r lint" }, "engines": { "node": ">=18", "pnpm": ">=8" } } "#), ("pnpm-workspace.yaml", r#"packages: - 'apps/*' - 'packages/*' "#), (".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"), ("apps/.gitkeep", ""), ("packages/.gitkeep", ""), (".gitignore", "node_modules/\ndist/\n.next/\n*.log\n.env\n.env.*\n!.env.example\n"), ], }, Tpl { id: "builtin-pnpm-app", name: "pnpm App", description: "单包 Node.js 应用", platform: "both", tech_stack: "Node,pnpm", init_commands: "pnpm install", files: vec![ ("package.json", r#"{ "name": "{{project_name_kebab}}", "version": "0.1.0", "private": true, "type": "module", "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js" } } "#), ("src/index.js", "console.log('Hello from {{project_name}}!')\n"), (".gitignore", "node_modules/\ndist/\n*.log\n.env\n"), ], }, Tpl { id: "builtin-rust", name: "Rust", description: "Rust 二进制项目", platform: "both", tech_stack: "Rust", init_commands: "cargo build", files: vec![ ("Cargo.toml", r#"[package] name = "{{project_name_kebab}}" version = "0.1.0" edition = "2021" [dependencies] "#), ("src/main.rs", "fn main() {\n println!(\"Hello from {{project_name}}!\");\n}\n"), (".gitignore", "target/\n"), ], }, Tpl { id: "builtin-empty", name: "空文件夹", description: "只创建目录,不添加任何文件", platform: "both", tech_stack: "", init_commands: "", files: vec![], }, ]; for t in &templates { let _ = conn.execute( "INSERT OR IGNORE INTO project_templates (id, name, description, platform, tech_stack, init_commands, is_builtin) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)", params![ t.id, t.name, if t.description.is_empty() { None } else { Some(t.description) }, t.platform, if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) }, if t.init_commands.is_empty() { None } else { Some(t.init_commands) }, ], ); for (path, content) in &t.files { let file_id = format!("{}-{}", t.id, path.replace('/', "-")); let _ = conn.execute( "INSERT OR IGNORE INTO project_template_files (id, template_id, path, content) VALUES (?1, ?2, ?3, ?4)", params![file_id, t.id, path, content], ); } } }