dev-manager-tauri/src-tauri/src/commands/templates.rs
lanrtop 3d9336d908 feat(templates): 三端体系内置模板 + 技术栈分类标签渲染
上一会话遗留的完整功能补提交:
- 新增 4 个三端体系内置模板:React Web / React + Capacitor /
  React + Tauri (Antd) / React Tri-Platform(同一架构按需起步)
- tech_stack 升级为分类 JSON 格式(客户端/语言/框架),
  新增 techStackUtils.renderTechStack 兼容旧逗号格式,
  ManagePage / NewProjectModal 接线,TemplateEditor 占位符同步

Feature-Confirmed: true
2026-07-18 10:48:27 +09:00

2429 lines
75 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: r#"{"":["Web"],"/":["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: r#"{"":["Web"],"/":["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: r#"{"":["CLI","Desktop"],"":["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: r#"{"":["Desktop"],"":["TypeScript","Rust"],"/":["React","Tauri","Node","pnpm"]}"#,
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: r#"{"":["Android","iOS"],"":["TypeScript"],"/":["React Native","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![],
},
Tpl {
id: "builtin-pnpm-expo-monorepo",
name: "pnpm Monorepo + Expo",
description: "pnpm 多包 Monorepoapps/ 内含 Expo Router (React Native) 应用packages/ 内含共享 @repo/types 包。Metro 已配置 monorepo 路径解析(含 Windows 兼容修复),适合多 App 扩展场景。",
platform: "both",
tech_stack: r#"{"":["Android","iOS","Web"],"":["TypeScript"],"/":["React Native","Expo","pnpm"]}"#,
init_commands: "pnpm install",
variants: None,
files: vec![
// ── monorepo 根 ──────────────────────────────────────────────
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"scripts": {
"start": "pnpm --filter app start",
"android": "pnpm --filter app android",
"ios": "pnpm --filter app ios",
"lint": "pnpm -r lint"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
}
"#),
("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n - 'packages/*'\n"),
("tsconfig.base.json", r#"{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", "build"]
}
"#),
(".gitignore", "node_modules/\ndist/\n.expo/\nandroid/\nios/\n*.log\n.env\n.env.*\n!.env.example\n"),
(".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"),
// ── packages/types ───────────────────────────────────────────
("packages/types/package.json", r#"{
"name": "@repo/types",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"files": ["src"],
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}
"#),
("packages/types/src/index.ts", "// 在此导出跨 App 共享的类型\nexport type {}\n"),
// ── apps/app ─────────────────────────────────────────────────
("apps/app/package.json", r#"{
"name": "app",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"@repo/types": "workspace:*",
"expo": "~52.0.0",
"expo-router": "~4.0.0",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-native": "0.76.3",
"react-native-gesture-handler": "~2.20.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~18.3.12",
"typescript": "^5.4.5"
}
}
"#),
("apps/app/app.json", r##"{
"expo": {
"name": "{{project_name}}",
"slug": "{{project_name_kebab}}",
"version": "1.0.0",
"orientation": "default",
"userInterfaceStyle": "light",
"scheme": "{{project_name_kebab}}",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.example.{{project_name_snake}}"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff"
},
"package": "com.example.{{project_name_snake}}"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
"##),
("apps/app/tsconfig.json", r#"{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@repo/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
"#),
("apps/app/babel.config.js", r#"module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}
"#),
// Metro 配置monorepo 路径解析 + Windows release 构建兼容修复
("apps/app/metro.config.js", r#"const { getDefaultConfig } = require('expo/metro-config')
const path = require('path')
const projectRoot = __dirname
const monorepoRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
// Windows release 构建兼容:强制 unstable_serverRoot 为 projectRoot
// 避免 Metro 与 RN Gradle 插件的路径基准不一致。
config.server = {
...config.server,
unstable_serverRoot: projectRoot,
}
// 让 Metro 监听 monorepo 根目录,确保根 node_modules 可被解析
config.watchFolders = [monorepoRoot]
// 模块解析:优先查 app 自己的 node_modules再查 monorepo 根
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
]
module.exports = config
"#),
("apps/app/expo-env.d.ts", "/// <reference types=\"expo/types\" />\n\n// NOTE: This file should not be edited and should be in your git ignore\n"),
("apps/app/app/_layout.tsx", r#"import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack>
<Stack.Screen name="index" options={{ title: '{{project_name}}' }} />
</Stack>
</>
)
}
"#),
("apps/app/app/index.tsx", r#"import { View, Text, StyleSheet } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function HomeScreen() {
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>{{project_name}}</Text>
<Text style={styles.subtitle}>Expo Router + pnpm Monorepo</Text>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
title: { fontSize: 24, fontWeight: 'bold' },
subtitle: { fontSize: 14, color: '#666' },
})
"#),
],
},
// ── P1: React + Vite SPA ─────────────────────────────────────────────
Tpl {
id: "builtin-vite-react",
name: "React + Vite SPA",
description: "纯前端 Web 应用React 19 + TypeScript + TailwindCSS v4 + React Router v7",
platform: "both",
tech_stack: r#"{"":["Web"],"":["TypeScript"],"/":["React","Vite","TailwindCSS"]}"#,
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": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19",
"react-dom": "^19",
"react-router": "^7"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
"#),
("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 { BrowserRouter } from "react-router";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
"#),
("src/App.tsx", r#"import { Routes, Route } from "react-router";
import Home from "./pages/Home";
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
</Routes>
);
}
"#),
("src/index.css", "@import \"tailwindcss\";\n"),
("src/pages/Home.tsx", r#"export default function Home() {
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">{{project_name}}</h1>
<p className="mt-2 text-gray-500">React + Vite + TailwindCSS</p>
</div>
</main>
);
}
"#),
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\n"),
],
},
// ── P1: Tauri + React + TailwindCSS ──────────────────────────────────
Tpl {
id: "builtin-tauri-react-tailwind",
name: "Tauri + React + Tailwind",
description: "桌面应用Tauri v2 + React 19 + TypeScript + TailwindCSS v4",
platform: "both",
tech_stack: r#"{"":["Desktop"],"":["TypeScript","Rust"],"/":["React","Tauri","TailwindCSS","Node","pnpm"]}"#,
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": {
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
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";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
"#),
("src/index.css", "@import \"tailwindcss\";\n"),
("src/App.tsx", r#"import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900">{{project_name}}</h1>
<p className="text-gray-500">Tauri v2 + React + TailwindCSS</p>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => setCount((c) => c + 1)}
>
count: {count}
</button>
</div>
</main>
);
}
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"),
],
},
// ── P3: Web + Mobile 共享 Monorepo ────────────────────────────────────
Tpl {
id: "builtin-web-mobile-monorepo",
name: "Web + Mobile Monorepo",
description: "跨平台代码复用packages/core 共享业务逻辑apps/webVite + Tailwind+ apps/mobileExpo + NativeWind",
platform: "both",
tech_stack: r#"{"":["Android","iOS","Web"],"":["TypeScript"],"/":["React","React Native","Expo","Vite","TailwindCSS","NativeWind","pnpm"]}"#,
init_commands: "pnpm install",
variants: None,
files: vec![
// ── 根目录 ──────────────────────────────────────────────────────
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
"build:web": "pnpm --filter web build",
"start:mobile": "pnpm --filter mobile start",
"android": "pnpm --filter mobile android",
"ios": "pnpm --filter mobile ios",
"typecheck": "pnpm -r typecheck"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
}
"#),
("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n - 'packages/*'\n"),
("tsconfig.base.json", r#"{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", "build"]
}
"#),
(".gitignore", "node_modules/\ndist/\nbuild/\n.expo/\nandroid/\nios/\n*.log\n.env\n.env.*\n!.env.example\n"),
(".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"),
// ── packages/types ──────────────────────────────────────────────
("packages/types/package.json", r#"{
"name": "@repo/types",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5"
}
}
"#),
("packages/types/tsconfig.json", r#"{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
"#),
("packages/types/src/index.ts", r#"// 在此定义跨平台共享的类型
export interface User {
id: string
name: string
email: string
}
export interface ApiResponse<T> {
data: T
error?: string
}
"#),
// ── packages/core ───────────────────────────────────────────────
("packages/core/package.json", r#"{
"name": "@repo/core",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/types": "workspace:*"
},
"devDependencies": {
"typescript": "^5"
}
}
"#),
("packages/core/tsconfig.json", r#"{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
"#),
("packages/core/src/index.ts", r#"// 在此导出跨平台共享的业务逻辑(纯 TS不依赖任何平台 API
export * from "./api"
"#),
("packages/core/src/api.ts", r#"import type { ApiResponse, User } from "@repo/types"
const BASE_URL = ""
export async function getUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`${BASE_URL}/api/users/${id}`)
if (!res.ok) return { data: null as unknown as User, error: res.statusText }
const data = await res.json()
return { data }
}
"#),
// ── apps/web ────────────────────────────────────────────────────
("apps/web/package.json", r#"{
"name": "web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core": "workspace:*",
"@repo/types": "workspace:*",
"react": "^19",
"react-dom": "^19",
"react-router": "^7"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("apps/web/tsconfig.json", r#"{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@repo/core": ["../../packages/core/src/index.ts"],
"@repo/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["src"]
}
"#),
("apps/web/vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
"#),
("apps/web/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>
"#),
("apps/web/src/main.tsx", r#"import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
"#),
("apps/web/src/index.css", "@import \"tailwindcss\";\n"),
("apps/web/src/App.tsx", r#"import { Routes, Route } from "react-router";
import Home from "./pages/Home";
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
</Routes>
);
}
"#),
("apps/web/src/pages/Home.tsx", r#"export default function Home() {
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">{{project_name}}</h1>
<p className="mt-2 text-gray-500">Web + Mobile Monorepo</p>
</div>
</main>
);
}
"#),
// ── apps/mobile ─────────────────────────────────────────────────
("apps/mobile/package.json", r#"{
"name": "mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core": "workspace:*",
"@repo/types": "workspace:*",
"expo": "~52.0.0",
"expo-router": "~4.0.0",
"expo-status-bar": "~2.0.0",
"nativewind": "^4.0.1",
"react": "18.3.1",
"react-native": "0.76.3",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~18.3.12",
"typescript": "^5.4.5"
}
}
"#),
("apps/mobile/app.json", r##"{
"expo": {
"name": "{{project_name}}",
"slug": "{{project_name_kebab}}",
"version": "1.0.0",
"scheme": "{{project_name_kebab}}",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.example.{{project_name_snake}}"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff"
},
"package": "com.example.{{project_name_snake}}"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
"##),
("apps/mobile/tsconfig.json", r#"{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@repo/core": ["../../packages/core/src/index.ts"],
"@repo/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
"#),
("apps/mobile/tailwind.config.js", r#"/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
}
"#),
("apps/mobile/global.css", "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"),
("apps/mobile/babel.config.js", r#"module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: ['nativewind/babel'],
}
}
"#),
("apps/mobile/metro.config.js", r#"const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')
const path = require('path')
const projectRoot = __dirname
const monorepoRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
config.watchFolders = [monorepoRoot]
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
]
module.exports = withNativeWind(config, { input: './global.css' })
"#),
("apps/mobile/expo-env.d.ts", "/// <reference types=\"expo/types\" />\n"),
("apps/mobile/app/_layout.tsx", r#"import '../global.css'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack>
<Stack.Screen name="index" options={{ title: '{{project_name}}' }} />
</Stack>
</>
)
}
"#),
("apps/mobile/app/index.tsx", r#"import { View, Text } from 'react-native'
export default function HomeScreen() {
return (
<View className="flex-1 items-center justify-center bg-gray-50">
<Text className="text-3xl font-bold text-gray-900">{{project_name}}</Text>
<Text className="mt-2 text-gray-500">Web + Mobile Monorepo</Text>
</View>
)
}
"#),
],
},
// ── 三端体系模板(同一架构,按需起步)────────────────────────────────────
Tpl {
id: "builtin-react-web",
name: "React Web",
description: "三端体系 Web 基础。React 18 + TypeScript + Ant Design 5 + Zustand可随时扩展为 Capacitor Android 或 Tauri Desktop。",
platform: "both",
tech_stack: r#"{"":[""],"":["Web"],"":["TypeScript"],"/":["React","Ant Design","Zustand","Vite","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": {
"web": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
};
});
"#),
("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>
"#),
(".env", "VITE_PLATFORM=web\n"),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\n"),
],
},
Tpl {
id: "builtin-react-capacitor",
name: "React + Capacitor",
description: "Web + Android。React 18 + Ant Design 5 + Zustand + Capacitor 8pnpm build:cap 打包为原生 APK。",
platform: "both",
tech_stack: r#"{"":[""],"":["Web","Android"],"":["TypeScript"],"/":["React","Ant Design","Zustand","Capacitor","Vite","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": {
"web": "vite",
"build": "vite build",
"build:cap": "vite build --mode capacitor && npx cap sync",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@capacitor/core": "^8.0.0",
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
};
});
"#),
("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", "capacitor.config.ts"]
}
"#),
("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>
"#),
(".env", "VITE_PLATFORM=web\n"),
(".env.capacitor", "VITE_PLATFORM=android\n"),
("capacitor.config.ts", r#"import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.{{project_name_snake}}",
appName: "{{project_name}}",
webDir: "dist",
};
export default config;
"#),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand + Capacitor</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\nandroid/\n"),
],
},
Tpl {
id: "builtin-react-tauri-antd",
name: "React + Tauri",
description: "Web + Desktop。React 18 + Ant Design 5 + Zustand + Tauri 2pnpm build:desktop 打包为原生桌面应用。",
platform: "both",
tech_stack: r#"{"":[""],"":["Web","Desktop"],"":["TypeScript","Rust"],"/":["React","Ant Design","Zustand","Tauri","Vite","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": {
"web": "vite",
"build": "vite build",
"build:desktop": "tauri build",
"dev:desktop": "tauri dev",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2",
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
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>
"#),
(".env", "VITE_PLATFORM=web\n"),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand + Tauri 2</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
("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 web",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm 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*.log\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
Tpl {
id: "builtin-react-triplatform",
name: "React Tri-Platform",
description: "三端合一。同一套 React 18 + Ant Design 5 + Zustand 代码pnpm web / build:cap / build:desktop 分别构建 Web / Android / Desktop。",
platform: "both",
tech_stack: r#"{"":[""],"":["Web","Android","Desktop"],"":["TypeScript","Rust"],"/":["React","Ant Design","Zustand","Capacitor","Tauri","Vite","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": {
"web": "vite",
"build": "vite build",
"build:cap": "vite build --mode capacitor && npx cap sync",
"build:desktop": "tauri build",
"dev:desktop": "tauri dev",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@capacitor/core": "^8.0.0",
"@tauri-apps/api": "^2",
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
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", "capacitor.config.ts"]
}
"#),
("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>
"#),
(".env", "VITE_PLATFORM=web\n"),
(".env.capacitor", "VITE_PLATFORM=android\n"),
("capacitor.config.ts", r#"import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.{{project_name_snake}}",
appName: "{{project_name}}",
webDir: "dist",
};
export default config;
"#),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand · Web / Android / Desktop</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
("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 web",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm 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*.log\n.env\n.env.*\n!.env.example\nandroid/\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
// ── 知识库(择升环)────────────────────────────────────────────────────
Tpl {
id: "builtin-knowledge-loop",
name: "知识库(择升环)",
description: "方法论 / 知识沉淀文档库。三层晋升结构dialogues 对话原料 → concepts 概念卡 → CORE 稳定核心,晋升规则内置于 AGENTS.mdagent 进库即按择升环工作流运转。",
platform: "both",
tech_stack: r#"{"":[""],"/":["Markdown","Git"]}"#,
init_commands: "git init",
variants: None,
files: vec![
("CORE.md", r#"# {{project_name}} · 核心层
> 本文件是知识库的稳定核心——只收录经过实践检验、表述已收敛的内容。
> ⚠️ 过滤铁律:全文上限 300 行。收录新内容超限时,必须合并或挤掉旧内容——
> 只升不择的核心层会失去判断力。
## 定义
<!-- 本知识库研究的核心概念,一句话可复述的稳定定义 -->
## 已验证原理
<!-- 每条原理注明来源,格式:
### 原理名
一段话陈述。
来源concepts/<概念卡>.md · 验证案例cases/<案例>.md
-->
## 修订记录
<!-- 日期 · 进了什么 · 挤掉/合并了什么 · 为什么 -->
"#),
("AGENTS.md", r#"# {{project_name}} — AI Agent 工作规程
本仓库是知识库,不是代码库。目录即晋升流水线:
```
dialogues/(对话原料,允许粗糙)
→ concepts/(概念卡,一事一文件)
→ CORE.md稳定核心上限 300 行)
```
cases/ 是检验层:概念卡晋升 CORE 的依据来自真实案例。
## 每次会话必须执行
1. 先读 `CORE.md` 与本次话题相关的 `concepts/` 卡——上一轮的结论是这一轮的输入
2. 探讨结束后,把整理稿写入 `dialogues/YYYY-MM-DD-主题.md`
3. 对照晋升规则检查是否触发晋升 / 降级,触发则执行
4. git commit 收尾——本库以 git 历史作为演化台账
## 晋升规则(择)
| 从 | 到 | 条件 |
|----|----|------|
| dialogues/ | concepts/ | 同一想法在 ≥2 次对话中出现,或 ≥1 个真实案例站住 |
| concepts/ | CORE.md | 状态「稳定」+ 经 cases/ 检验 + 表述收敛为一段话 |
降级同样重要被案例证伪的概念卡标记废弃CORE.md 超 300 行必须挤旧。
只升不择,核心层会膨胀成噪音。
## 写作纪律
- 单一事实来源:同一结论只在一层的一个文件里详述,其他位置引用
- 概念卡必须写「边界」(什么条件下不成立)——没有边界的结论不可信
- 修改 CORE.md 必须在其修订记录写明:进了什么、挤了什么、为什么
- 案例复盘必做归因二分:执行缺口(概念没用好)修行动;
规格缺口(概念本身有漏洞)回改概念卡——后者是全库最值钱的信号
"#),
("concepts/README.md", r#"# concepts/ — 概念卡层
一个概念一个文件,文件名 kebab-case如 `pinggu-wai-huan.md`)。
## 概念卡格式
```markdown
# 概念名
- 状态:孵化 | 稳定 | 已入核 | 已废弃
- 出处dialogues/YYYY-MM-DD-xxx.md
- 最近验证YYYY-MM-DDcases/xxx.md
## 定义
一段话说清它是什么。
## 为什么成立
支撑它的推理或证据。
## 边界
它在什么条件下不成立。
```
## 晋升与降级
- 晋升进 CORE.md 后状态改「已入核」详述保留在卡内CORE 只收一段话结论
- 被案例证伪 → 状态改「已废弃」并写明证伪案例,不直接删(失败也是判断力)
"#),
("dialogues/README.md", r#"# dialogues/ — 对话沉淀层(原料)
每次探讨结束后,把整理稿存入本目录。这一层的目标是「不丢」,不是「精炼」。
- 命名:`YYYY-MM-DD-主题.md`
- 开头写三行摘要:讨论了什么 / 新想法 / 待验证
- 同一想法第二次出现时,检查是否触发晋升为概念卡
"#),
("cases/README.md", r#"# cases/ — 实例复盘层(检验)
记录概念在真实事件中的应用与翻车。案例是概念卡晋升 CORE 的检验依据。
- 命名:`YYYY-MM-DD-事件.md`
- 必写字段:用了哪个概念 / 预期 / 实际结果 / 归因(执行缺口 or 规格缺口)
- 规格缺口 → 回改概念卡;执行缺口 → 概念不动,记录教训
"#),
(".gitignore", ".DS_Store\nThumbs.db\n*.tmp\n"),
],
},
];
for t in &templates {
let _ = conn.execute(
"INSERT INTO project_templates
(id, name, description, platform, tech_stack, init_commands, is_builtin, variants)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
tech_stack = excluded.tech_stack,
init_commands = excluded.init_commands,
variants = excluded.variants",
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,
],
);
for (path, content) in &t.files {
let file_id = format!("{}-{}", t.id, path.replace('/', "-"));
let _ = conn.execute(
"INSERT OR REPLACE INTO project_template_files (id, template_id, path, content)
VALUES (?1, ?2, ?3, ?4)",
params![file_id, t.id, path, content],
);
}
}
}