From d33baeb3f7cab74bc8b91b8651ef22fc701f7d38 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 11 Apr 2026 15:22:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=A1=E6=9D=BF=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=8F=AF=E8=A7=86=E5=8C=96=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E6=97=A5=E5=BF=97=20+=20=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E5=99=A8=20+=20=E8=AE=A1=E6=97=B6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/templates.rs | 75 ++++++--- src/components/manage/NewProjectModal.tsx | 185 +++++++++++++++++++--- src/index.css | 6 + 3 files changed, 221 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/commands/templates.rs b/src-tauri/src/commands/templates.rs index c558c5b..5b422f1 100644 --- a/src-tauri/src/commands/templates.rs +++ b/src-tauri/src/commands/templates.rs @@ -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, ) -> Result { + use tauri::Emitter; + let tmpl = get_template(template_id)?; let mut logs: Vec = 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, diff --git a/src/components/manage/NewProjectModal.tsx b/src/components/manage/NewProjectModal.tsx index 79f1d49..b8bde50 100644 --- a/src/components/manage/NewProjectModal.tsx +++ b/src/components/manage/NewProjectModal.tsx @@ -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 = { 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([]); const [step, setStep] = useState("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(null); + const timerRef = useRef | 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("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 (
@@ -228,27 +286,107 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
)} - {/* ── Step 3/4:执行日志 ── */} + {/* ── Step 3/4:执行进度 ── */} {(step === "executing" || step === "done") && ( -
- {logs.map((line, i) => ( -
- {line} +
+ + {/* 步骤指示器 */} +
+ {EXEC_STEPS.map((s) => { + const isDone = step === "done" ? execStep >= s.id : execStep > s.id; + const isActive = step === "executing" && execStep === s.id; + + return ( +
+ {/* 图标 */} +
+ {isDone ? "✓" : isActive ? ( + + ) : s.id} +
+ + {/* 标签 */} +
+
+ {s.icon} {s.label} + {isActive && s.id === 2 && currentCmd && ( + {currentCmd} + )} +
+ + {/* 进度条(仅当前步骤显示)*/} + {isActive && ( +
+
+
+ )} +
+ + {/* 计时 */} + {isActive && ( + {fmtTime(elapsed)} + )} + {isDone && s.id === execStep - 1 && step === "executing" && ( + + )} +
+ ); + })} +
+ + {/* 实时日志(可滚动,最近 200 行)*/} +
+
+ 输出日志 + {step === "executing" && ( + ● 实时 + )} +
+
+ {logs.length === 0 && ( +
等待输出…
+ )} + {logs.slice(-200).map((line, i) => ( +
+ {line} +
+ ))} +
+
+
+ + {/* 完成提示 */} + {step === "done" && !hasError && ( +
+ 🎉 项目已创建完成!共耗时 {fmtTime(elapsed)}
- ))} - {step === "executing" && ( -
运行中…
)} - {step === "done" && !logs.some((l) => l.startsWith("❌")) && ( -
- 🎉 项目已创建完成! + {step === "done" && hasError && ( +
+ ⚠️ 执行过程中出现错误,请查看日志
)}
@@ -270,7 +408,8 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
diff --git a/src/index.css b/src/index.css index 23a8ba7..465ddfb 100644 --- a/src/index.css +++ b/src/index.css @@ -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%); } +}