feat: 模板创建进度可视化——流式日志 + 步骤指示器 + 计时器

This commit is contained in:
lanrtop 2026-04-11 15:22:38 +09:00
parent 26860a0b4d
commit d33baeb3f7
3 changed files with 221 additions and 45 deletions

View File

@ -1,7 +1,9 @@
use crate::db; use crate::db;
use rusqlite::params; use rusqlite::params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::Path; use std::path::Path;
use std::process::Stdio;
use uuid::Uuid; use uuid::Uuid;
// ── 数据结构 ────────────────────────────────────────────────────────────────── // ── 数据结构 ──────────────────────────────────────────────────────────────────
@ -186,15 +188,27 @@ pub fn delete_template(id: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub fn create_project_from_template( pub fn create_project_from_template(
app: tauri::AppHandle,
template_id: String, template_id: String,
parent_path: String, parent_path: String,
project_name: String, project_name: String,
platform: String, platform: String,
space_id: Option<String>, space_id: Option<String>,
) -> Result<CreateProjectResult, String> { ) -> Result<CreateProjectResult, String> {
use tauri::Emitter;
let tmpl = get_template(template_id)?; let tmpl = get_template(template_id)?;
let mut logs: Vec<String> = Vec::new(); 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. 计算项目路径 ─────────────────────────────────────────────────────── // ── 1. 计算项目路径 ───────────────────────────────────────────────────────
let win_parent = crate::commands::publish::wsl_to_win(&parent_path); let win_parent = crate::commands::publish::wsl_to_win(&parent_path);
let project_win_path = format!( let project_win_path = format!(
@ -209,9 +223,10 @@ pub fn create_project_from_template(
} }
// ── 2. 创建根目录 ───────────────────────────────────────────────────────── // ── 2. 创建根目录 ─────────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:1");
std::fs::create_dir_all(project_root) std::fs::create_dir_all(project_root)
.map_err(|e| format!("创建目录失败: {e}"))?; .map_err(|e| format!("创建目录失败: {e}"))?;
logs.push(format!("📁 已创建目录: {}", project_win_path)); log_push!(format!("📁 已创建目录: {}", project_win_path));
// ── 3. 写入模板文件 ─────────────────────────────────────────────────────── // ── 3. 写入模板文件 ───────────────────────────────────────────────────────
for file in &tmpl.files { for file in &tmpl.files {
@ -226,10 +241,12 @@ pub fn create_project_from_template(
} }
std::fs::write(&file_path, content) std::fs::write(&file_path, content)
.map_err(|e| format!("写入 {} 失败: {e}", file.path))?; .map_err(|e| format!("写入 {} 失败: {e}", file.path))?;
logs.push(format!("📄 {}", file.path)); log_push!(format!("📄 {}", file.path));
} }
// ── 4. 执行初始化命令 ───────────────────────────────────────────────────── // ── 4. 执行初始化命令 ─────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:2");
if let Some(cmds) = &tmpl.init_commands { if let Some(cmds) = &tmpl.init_commands {
let linux_project_path = if platform == "wsl" { let linux_project_path = if platform == "wsl" {
crate::commands::publish::extract_linux_path(&parent_path, &win_parent) crate::commands::publish::extract_linux_path(&parent_path, &win_parent)
@ -244,46 +261,61 @@ pub fn create_project_from_template(
continue; continue;
} }
logs.push(format!("⚙ 执行: {}", cmd)); let _ = app.emit("template:progress", &format!("__cmd:{}", cmd));
log_push!(format!("⚙ 执行: {}", cmd));
let output = if platform == "wsl" { // 用 spawn 流式读 stdout实时推送每行输出
let lp = linux_project_path let spawn_result = if platform == "wsl" {
.as_deref() let lp = linux_project_path.as_deref().unwrap_or(&project_win_path);
.unwrap_or(&project_win_path);
crate::silent_cmd("wsl") crate::silent_cmd("wsl")
.args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)]) .args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)])
.output() .stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
} else { } else {
crate::silent_cmd("cmd") crate::silent_cmd("cmd")
.args(["/c", &format!("chcp 65001 > nul && {}", cmd)]) .args(["/c", &format!("chcp 65001 > nul && {}", cmd)])
.current_dir(&project_win_path) .current_dir(&project_win_path)
.output() .stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
}; };
match output { match spawn_result {
Ok(out) => { Ok(mut child) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); // 流式读 stdout每行立即 emit
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if let Some(stdout) = child.stdout.take() {
if !stdout.is_empty() { for line in BufReader::new(stdout).lines() {
logs.push(stdout); 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() { if !stderr.is_empty() {
logs.push(format!("{}", stderr)); log_push!(format!("{}", stderr));
} }
if out.status.success() { if output.status.success() {
logs.push("".to_string()); log_push!("".to_string());
} else { } else {
logs.push(format!(" ✗ 退出码: {}", out.status)); log_push!(format!(" ✗ 退出码: {}", output.status));
} }
} }
Err(e) => { Err(e) => {
logs.push(format!(" ✗ 启动失败: {e}")); log_push!(format!(" ✗ 启动失败: {e}"));
} }
} }
} }
} }
// ── 5. 注册到数据库 ─────────────────────────────────────────────────────── // ── 5. 注册到数据库 ───────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:3");
let conn = db::pool().get().map_err(|e| e.to_string())?; let conn = db::pool().get().map_err(|e| e.to_string())?;
let profile_id = Uuid::new_v4().to_string(); let profile_id = Uuid::new_v4().to_string();
let workspace_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 { } else {
(None, Some(&project_win_path)) (None, Some(&project_win_path))
}; };
// 存储时统一用 "windows"(与其他创建路径保持一致)
let stored_platform = if is_wsl { "wsl" } else { "windows" }; let stored_platform = if is_wsl { "wsl" } else { "windows" };
conn.execute( conn.execute(
@ -311,7 +342,7 @@ pub fn create_project_from_template(
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
logs.push(format!("✅ 项目已注册ID: {}", workspace_id)); log_push!(format!("✅ 项目已注册ID: {}", workspace_id));
Ok(CreateProjectResult { Ok(CreateProjectResult {
project_id: workspace_id, project_id: workspace_id,

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event";
import { import {
listTemplates, createProjectFromTemplate, listTemplates, createProjectFromTemplate,
} from "../../lib/commands"; } from "../../lib/commands";
@ -18,6 +19,12 @@ const PLATFORM_LABEL: Record<string, string> = {
wsl: "仅 WSL", wsl: "仅 WSL",
}; };
const EXEC_STEPS = [
{ id: 1, label: "创建文件结构", icon: "📁" },
{ id: 2, label: "安装依赖", icon: "📦" },
{ id: 3, label: "注册项目", icon: "🗃️" },
];
export function NewProjectModal({ spaceId, onClose }: Props) { export function NewProjectModal({ spaceId, onClose }: Props) {
const showToast = useUIStore((s) => s.showToast); const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient(); const qc = useQueryClient();
@ -33,6 +40,34 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
// ── Step 3/4 ────────────────────────────────────────────────────────────── // ── Step 3/4 ──────────────────────────────────────────────────────────────
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<string[]>([]);
const [step, setStep] = useState<Step>("config"); 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({ const { data: templates = [] } = useQuery({
queryKey: ["templates"], queryKey: ["templates"],
@ -61,25 +96,48 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
// ── 执行创建 ────────────────────────────────────────────────────────────── // ── 执行创建 ──────────────────────────────────────────────────────────────
const handleCreate = async () => { const handleCreate = async () => {
if (!selectedTemplateId || !parentPath || !projectName.trim()) return; if (!selectedTemplateId || !parentPath || !projectName.trim()) return;
setStep("executing"); 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 { try {
const result = await createProjectFromTemplate( await createProjectFromTemplate(
selectedTemplateId, selectedTemplateId,
parentPath, parentPath,
projectName.trim(), projectName.trim(),
platform, platform,
spaceId, spaceId,
); );
setLogs(result.logs);
qc.invalidateQueries({ queryKey: ["projects"] }); qc.invalidateQueries({ queryKey: ["projects"] });
setStep("done"); setStep("done");
} catch (e) { } catch (e) {
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]); setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
setHasError(true);
setStep("done"); setStep("done");
} finally {
unlisten();
} }
}; };
const fmtTime = (s: number) =>
s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
// ── 渲染 ────────────────────────────────────────────────────────────────── // ── 渲染 ──────────────────────────────────────────────────────────────────
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <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> </div>
)} )}
{/* ── Step 3/4执行日志 ── */} {/* ── Step 3/4执行进度 ── */}
{(step === "executing" || step === "done") && ( {(step === "executing" || step === "done") && (
<div className="space-y-1"> <div className="space-y-4">
{logs.map((line, i) => (
<div key={i} className={`text-xs font-mono leading-relaxed ${ {/* 步骤指示器 */}
line.startsWith("❌") ? "text-red-600" : <div className="space-y-2">
line.startsWith("✅") ? "text-green-600 font-semibold" : {EXEC_STEPS.map((s) => {
line.startsWith("⚙") ? "text-blue-600" : const isDone = step === "done" ? execStep >= s.id : execStep > s.id;
line.startsWith("⚠") ? "text-orange-500" : const isActive = step === "executing" && execStep === s.id;
line.startsWith("📁") || line.startsWith("📄") ? "text-gray-700" :
"text-gray-400" 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} {line}
</div> </div>
))} ))}
{step === "executing" && ( <div ref={logEndRef} />
<div className="text-xs text-gray-400 animate-pulse mt-2"></div> </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 === "done" && !logs.some((l) => l.startsWith("❌")) && ( {step === "done" && hasError && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center"> <div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700 text-center">
🎉
</div> </div>
)} )}
</div> </div>
@ -270,7 +408,8 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onClose} 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" ? "关闭" : "取消"} {step === "done" ? "关闭" : "取消"}
</button> </button>

View File

@ -4,3 +4,9 @@
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
/* 模板执行进度条动画 */
@keyframes indeterminate-progress {
0% { transform: translateX(-150%); }
100% { transform: translateX(300%); }
}