dev-manager-tauri/src-tauri/src/commands/templates.rs
lanrtop 8c06aa5e44 feat: 飞轮智能阶段二完成——MCP SSE 传输 + 复盘覆盖率 + usage.json 扩展采集
- MCP Server 新增 GET/SSE 端点,Claude Code 原生 MCP 打通
- 项目导师面板显示复盘笔记覆盖率统计与缺口提醒
- usage.json 快照新增 complexity_dist、rework_count 等扩展字段
- 独立项目自动创建产品组并注入 MCP
- 新增 retro-catchup 技能用于批量补写历史复盘笔记
- 补全飞轮存量函数单元测试
- NewProjectModal 支持更灵活的模板参数配置
2026-04-13 01:49:56 +09:00

726 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::db;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::Stdio;
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<String>,
pub platform: String, // "win" | "wsl" | "both"
pub tech_stack: Option<String>,
pub init_commands: Option<String>,
pub is_builtin: bool,
pub created_at: Option<String>,
#[serde(default)]
pub files: Vec<TemplateFile>,
/// JSON 数组,格式:[{label, description, initCommands}]
/// 存在时前端显示下拉让用户选择初始化方式
pub variants: Option<String>,
}
#[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<String>,
pub name: String,
pub description: Option<String>,
pub platform: String,
pub tech_stack: Option<String>,
pub init_commands: Option<String>,
pub files: Vec<TemplateFileInput>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProjectResult {
pub project_id: String,
pub logs: Vec<String>,
}
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_templates() -> Result<Vec<ProjectTemplate>, 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, variants
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![],
variants: row.get(8)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(templates)
}
#[tauri::command]
pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
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, variants
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![],
variants: row.get(8)?,
})
},
)
.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::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(tmpl)
}
#[tauri::command]
pub fn save_template(input: SaveTemplateInput) -> Result<String, String> {
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(
app: tauri::AppHandle,
template_id: String,
parent_path: String,
project_name: String,
platform: String,
space_id: Option<String>,
variant_init_commands: Option<String>,
) -> Result<CreateProjectResult, String> {
use tauri::Emitter;
let mut tmpl = get_template(template_id)?;
// 如果用户选择了 variant用其 initCommands 覆盖模板默认值
if let Some(v) = variant_init_commands {
tmpl.init_commands = Some(v);
}
let mut logs: Vec<String> = Vec::new();
// 辅助:同时 emit 事件 + 记录到 logs
macro_rules! log_push {
($msg:expr) => {{
let m: String = $msg;
let _ = app.emit("template:progress", &m);
logs.push(m);
}};
}
// ── 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. 创建根目录 ─────────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:1");
std::fs::create_dir_all(project_root)
.map_err(|e| format!("创建目录失败: {e}"))?;
log_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))?;
log_push!(format!("📄 {}", file.path));
}
// ── 4. 执行初始化命令 ─────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:2");
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;
}
let _ = app.emit("template:progress", &format!("__cmd:{}", cmd));
log_push!(format!("⚙ 执行: {}", cmd));
// 用 spawn 流式读 stdout实时推送每行输出
let spawn_result = if platform == "wsl" {
let lp = linux_project_path.as_deref().unwrap_or(&project_win_path);
crate::silent_cmd("wsl")
.args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
} else {
crate::silent_cmd("cmd")
.args(["/c", &format!("chcp 65001 > nul && {}", cmd)])
.current_dir(&project_win_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
};
match spawn_result {
Ok(mut child) => {
// 流式读 stdout每行立即 emit
if let Some(stdout) = child.stdout.take() {
for line in BufReader::new(stdout).lines() {
if let Ok(line) = line {
let line = line.trim().to_string();
if !line.is_empty() {
log_push!(line);
}
}
}
}
// 等进程结束,收集 stderr
let output = child.wait_with_output().map_err(|e| e.to_string())?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
log_push!(format!("{}", stderr));
}
if output.status.success() {
log_push!("".to_string());
} else {
log_push!(format!(" ✗ 退出码: {}", output.status));
}
}
Err(e) => {
log_push!(format!(" ✗ 启动失败: {e}"));
}
}
}
}
// ── 5. 注册到数据库 ───────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:3");
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))
};
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())?;
log_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,
};
// 不做全局跳过,直接用 INSERT OR IGNORE 保证幂等
// 这样新增内置模板时,已有用户也能自动获得
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)>,
/// JSON 字符串,存在时前端显示下拉选择初始化方式
variants: Option<&'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",
variants: None,
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",
variants: None,
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",
variants: None,
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: "",
variants: None,
files: vec![],
},
Tpl {
id: "builtin-tauri-react",
name: "Tauri + React",
description: "桌面应用Tauri v2 + React 19 + TypeScript + Vite",
platform: "both",
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri",
init_commands: "pnpm install",
variants: None,
files: vec![
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));
"#),
("tsconfig.json", r#"{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
"#),
("index.html", r#"<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{project_name}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
"#),
("src/main.tsx", r#"import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
"#),
("src/App.tsx", r#"import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>Tauri v2 + React + TypeScript</p>
<button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
</div>
);
}
export default App;
"#),
("src-tauri/tauri.conf.json", r#"{
"$schema": "https://schema.tauri.app/config/2",
"productName": "{{project_name_kebab}}",
"version": "0.1.0",
"identifier": "com.example.{{project_name_snake}}",
"build": {
"beforeDevCommand": "pnpm vite",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm vite build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "{{project_name}}",
"width": 1200,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": []
}
}
"#),
("src-tauri/Cargo.toml", r#"[package]
name = "{{project_name_snake}}"
version = "0.1.0"
description = "A Tauri App"
authors = []
edition = "2021"
[lib]
name = "{{project_name_snake}}_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
"#),
("src-tauri/build.rs", "fn main() {\n tauri_build::build()\n}\n"),
("src-tauri/src/main.rs", r#"// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
{{project_name_snake}}_lib::run()
}
"#),
("src-tauri/src/lib.rs", r#"#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
"#),
("src-tauri/capabilities/default.json", r#"{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
"#),
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
Tpl {
id: "builtin-expo",
name: "Expo",
description: "React Native 移动端 AppAndroid / iOS。TypeScript + Expo SDK适合游戏、工具、内容类手机应用。选择下方模板变体来确定初始化方式。",
platform: "both",
tech_stack: "React Native,TypeScript,Expo",
init_commands: "pnpm create expo-app@latest . --template blank-typescript",
variants: Some(r#"[
{"label":"blank-typescript推荐","description":"最小化 TypeScript 起步,适合游戏类、工具类、独立 App","initCommands":"pnpm create expo-app@latest . --template blank-typescript"},
{"label":"tabs底部标签导航","description":"内置底部 Tab 栏 + Expo Router适合多页面内容型 App","initCommands":"pnpm create expo-app@latest . --template tabs"},
{"label":"bare裸原生配置","description":"完整原生 Expo 配置,可深度定制原生模块,适合进阶项目","initCommands":"pnpm create expo-app@latest . --template bare-minimum"},
{"label":"默认(向导模式)","description":"运行官方交互式向导,在终端中选择模板类型","initCommands":"pnpm create expo-app@latest ."}
]"#),
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, variants)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)",
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) },
t.variants,
],
);
// 已存在的模板也补充 variantsINSERT OR IGNORE 不更新已有行)
let _ = conn.execute(
"UPDATE project_templates SET variants = ?1 WHERE id = ?2 AND is_builtin = 1",
params![t.variants, t.id],
);
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],
);
}
}
}