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 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,

View File

@ -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>

View File

@ -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%); }
}