feat: 模板创建进度可视化——流式日志 + 步骤指示器 + 计时器
This commit is contained in:
parent
26860a0b4d
commit
d33baeb3f7
@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
||||
// ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||
@ -186,15 +188,27 @@ pub fn delete_template(id: String) -> Result<(), String> {
|
||||
|
||||
#[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>,
|
||||
) -> Result<CreateProjectResult, String> {
|
||||
use tauri::Emitter;
|
||||
|
||||
let tmpl = get_template(template_id)?;
|
||||
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!(
|
||||
@ -209,9 +223,10 @@ pub fn create_project_from_template(
|
||||
}
|
||||
|
||||
// ── 2. 创建根目录 ─────────────────────────────────────────────────────────
|
||||
let _ = app.emit("template:progress", "__step:1");
|
||||
std::fs::create_dir_all(project_root)
|
||||
.map_err(|e| format!("创建目录失败: {e}"))?;
|
||||
logs.push(format!("📁 已创建目录: {}", project_win_path));
|
||||
log_push!(format!("📁 已创建目录: {}", project_win_path));
|
||||
|
||||
// ── 3. 写入模板文件 ───────────────────────────────────────────────────────
|
||||
for file in &tmpl.files {
|
||||
@ -226,10 +241,12 @@ pub fn create_project_from_template(
|
||||
}
|
||||
std::fs::write(&file_path, content)
|
||||
.map_err(|e| format!("写入 {} 失败: {e}", file.path))?;
|
||||
logs.push(format!("📄 {}", 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)
|
||||
@ -244,46 +261,61 @@ pub fn create_project_from_template(
|
||||
continue;
|
||||
}
|
||||
|
||||
logs.push(format!("⚙ 执行: {}", cmd));
|
||||
let _ = app.emit("template:progress", &format!("__cmd:{}", cmd));
|
||||
log_push!(format!("⚙ 执行: {}", cmd));
|
||||
|
||||
let output = if platform == "wsl" {
|
||||
let lp = linux_project_path
|
||||
.as_deref()
|
||||
.unwrap_or(&project_win_path);
|
||||
// 用 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)])
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
} else {
|
||||
crate::silent_cmd("cmd")
|
||||
.args(["/c", &format!("chcp 65001 > nul && {}", cmd)])
|
||||
.current_dir(&project_win_path)
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
};
|
||||
|
||||
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);
|
||||
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() {
|
||||
logs.push(format!("⚠ {}", stderr));
|
||||
log_push!(format!("⚠ {}", stderr));
|
||||
}
|
||||
if out.status.success() {
|
||||
logs.push(" ✓".to_string());
|
||||
if output.status.success() {
|
||||
log_push!(" ✓".to_string());
|
||||
} else {
|
||||
logs.push(format!(" ✗ 退出码: {}", out.status));
|
||||
log_push!(format!(" ✗ 退出码: {}", output.status));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logs.push(format!(" ✗ 启动失败: {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();
|
||||
@ -301,7 +333,6 @@ pub fn create_project_from_template(
|
||||
} else {
|
||||
(None, Some(&project_win_path))
|
||||
};
|
||||
// 存储时统一用 "windows"(与其他创建路径保持一致)
|
||||
let stored_platform = if is_wsl { "wsl" } else { "windows" };
|
||||
|
||||
conn.execute(
|
||||
@ -311,7 +342,7 @@ pub fn create_project_from_template(
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
logs.push(format!("✅ 项目已注册,ID: {}", workspace_id));
|
||||
log_push!(format!("✅ 项目已注册,ID: {}", workspace_id));
|
||||
|
||||
Ok(CreateProjectResult {
|
||||
project_id: workspace_id,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
listTemplates, createProjectFromTemplate,
|
||||
} from "../../lib/commands";
|
||||
@ -18,6 +19,12 @@ const PLATFORM_LABEL: Record<string, string> = {
|
||||
wsl: "仅 WSL",
|
||||
};
|
||||
|
||||
const EXEC_STEPS = [
|
||||
{ id: 1, label: "创建文件结构", icon: "📁" },
|
||||
{ id: 2, label: "安装依赖", icon: "📦" },
|
||||
{ id: 3, label: "注册项目", icon: "🗃️" },
|
||||
];
|
||||
|
||||
export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
@ -33,6 +40,34 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
// ── Step 3/4 ──────────────────────────────────────────────────────────────
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [step, setStep] = useState<Step>("config");
|
||||
const [execStep, setExecStep] = useState(0); // 1 / 2 / 3
|
||||
const [currentCmd, setCurrentCmd] = useState(""); // 当前执行的命令
|
||||
const [elapsed, setElapsed] = useState(0); // 秒
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 日志自动滚到底
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
// 计时器:执行时启动,完成时停止
|
||||
useEffect(() => {
|
||||
if (step === "executing") {
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [step]);
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ["templates"],
|
||||
@ -61,25 +96,48 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
// ── 执行创建 ──────────────────────────────────────────────────────────────
|
||||
const handleCreate = async () => {
|
||||
if (!selectedTemplateId || !parentPath || !projectName.trim()) return;
|
||||
|
||||
setStep("executing");
|
||||
setLogs(["⏳ 正在创建项目…"]);
|
||||
setLogs([]);
|
||||
setExecStep(0);
|
||||
setCurrentCmd("");
|
||||
setHasError(false);
|
||||
|
||||
// 监听后端流式事件
|
||||
const unlisten = await listen<string>("template:progress", (event) => {
|
||||
const msg = event.payload;
|
||||
if (msg.startsWith("__step:")) {
|
||||
setExecStep(parseInt(msg.slice(7)));
|
||||
} else if (msg.startsWith("__cmd:")) {
|
||||
setCurrentCmd(msg.slice(6));
|
||||
} else {
|
||||
setLogs((prev) => [...prev, msg]);
|
||||
if (msg.startsWith("❌") || msg.includes("✗")) setHasError(true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await createProjectFromTemplate(
|
||||
await createProjectFromTemplate(
|
||||
selectedTemplateId,
|
||||
parentPath,
|
||||
projectName.trim(),
|
||||
platform,
|
||||
spaceId,
|
||||
);
|
||||
setLogs(result.logs);
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
|
||||
setHasError(true);
|
||||
setStep("done");
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
|
||||
const fmtTime = (s: number) =>
|
||||
s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
|
||||
// ── 渲染 ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
@ -228,27 +286,107 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3/4:执行日志 ── */}
|
||||
{/* ── Step 3/4:执行进度 ── */}
|
||||
{(step === "executing" || step === "done") && (
|
||||
<div className="space-y-1">
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className={`text-xs font-mono leading-relaxed ${
|
||||
line.startsWith("❌") ? "text-red-600" :
|
||||
line.startsWith("✅") ? "text-green-600 font-semibold" :
|
||||
line.startsWith("⚙") ? "text-blue-600" :
|
||||
line.startsWith("⚠") ? "text-orange-500" :
|
||||
line.startsWith("📁") || line.startsWith("📄") ? "text-gray-700" :
|
||||
"text-gray-400"
|
||||
}`}>
|
||||
{line}
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<div className="space-y-2">
|
||||
{EXEC_STEPS.map((s) => {
|
||||
const isDone = step === "done" ? execStep >= s.id : execStep > s.id;
|
||||
const isActive = step === "executing" && execStep === s.id;
|
||||
|
||||
return (
|
||||
<div key={s.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
||||
isActive ? "bg-blue-50 border border-blue-100" :
|
||||
isDone ? "bg-green-50 border border-green-100" :
|
||||
"bg-gray-50 border border-gray-100"
|
||||
}`}>
|
||||
{/* 图标 */}
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs shrink-0 ${
|
||||
isDone ? "bg-green-500 text-white" :
|
||||
isActive ? "bg-blue-500 text-white" :
|
||||
"bg-gray-200 text-gray-400"
|
||||
}`}>
|
||||
{isDone ? "✓" : isActive ? (
|
||||
<span className="animate-spin inline-block">⟳</span>
|
||||
) : s.id}
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-medium ${
|
||||
isActive ? "text-blue-700" : isDone ? "text-green-700" : "text-gray-400"
|
||||
}`}>
|
||||
{s.icon} {s.label}
|
||||
{isActive && s.id === 2 && currentCmd && (
|
||||
<span className="ml-2 font-mono text-blue-500 font-normal">{currentCmd}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条(仅当前步骤显示)*/}
|
||||
{isActive && (
|
||||
<div className="mt-1.5 h-1 bg-blue-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-400 rounded-full"
|
||||
style={{
|
||||
width: "40%",
|
||||
animation: "indeterminate-progress 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 计时 */}
|
||||
{isActive && (
|
||||
<span className="text-xs text-blue-400 shrink-0 tabular-nums">{fmtTime(elapsed)}</span>
|
||||
)}
|
||||
{isDone && s.id === execStep - 1 && step === "executing" && (
|
||||
<span className="text-xs text-green-500 shrink-0">✓</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 实时日志(可滚动,最近 200 行)*/}
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-1.5 border-b border-gray-700 flex items-center gap-2">
|
||||
<span className="text-[10px] text-gray-400 font-mono">输出日志</span>
|
||||
{step === "executing" && (
|
||||
<span className="text-[10px] text-blue-400 animate-pulse">● 实时</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-40 overflow-y-auto px-3 py-2 space-y-0.5">
|
||||
{logs.length === 0 && (
|
||||
<div className="text-[11px] text-gray-500 font-mono animate-pulse">等待输出…</div>
|
||||
)}
|
||||
{logs.slice(-200).map((line, i) => (
|
||||
<div key={i} className={`text-[11px] font-mono leading-relaxed ${
|
||||
line.startsWith("❌") || line.includes("✗") ? "text-red-400" :
|
||||
line.startsWith("✅") ? "text-green-400 font-semibold" :
|
||||
line.startsWith("⚙") ? "text-blue-400" :
|
||||
line.startsWith("⚠") ? "text-yellow-400" :
|
||||
line.startsWith("📁") || line.startsWith("📄") ? "text-gray-300" :
|
||||
"text-gray-500"
|
||||
}`}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完成提示 */}
|
||||
{step === "done" && !hasError && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center">
|
||||
🎉 项目已创建完成!共耗时 {fmtTime(elapsed)}
|
||||
</div>
|
||||
))}
|
||||
{step === "executing" && (
|
||||
<div className="text-xs text-gray-400 animate-pulse mt-2">运行中…</div>
|
||||
)}
|
||||
{step === "done" && !logs.some((l) => l.startsWith("❌")) && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center">
|
||||
🎉 项目已创建完成!
|
||||
{step === "done" && hasError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700 text-center">
|
||||
⚠️ 执行过程中出现错误,请查看日志
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -270,7 +408,8 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
disabled={step === "executing"}
|
||||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
{step === "done" ? "关闭" : "取消"}
|
||||
</button>
|
||||
|
||||
@ -4,3 +4,9 @@
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
|
||||
/* 模板执行进度条动画 */
|
||||
@keyframes indeterminate-progress {
|
||||
0% { transform: translateX(-150%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user